Browse Source

Merge pull request #3586 from deepseek-harness/session-migration-preparation

perf(session): prepare historical reads before publication
imccyu 4 weeks ago
parent
commit
450d98b398
98 changed files with 1894 additions and 964 deletions
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml
  5. 5 16
      .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
  6. 5 16
      .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.i18n.yaml
  8. 2 2
      .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md
  9. 2 2
      .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md
  10. 6 0
      .agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml
  11. 170 0
      .agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md
  12. 170 0
      .agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md
  13. 1 1
      apps/web/tests/scaffold.ts
  14. 1 1
      apps/web/tests/schedule-after.e2e.ts
  15. 1 1
      benchmarks/session-open/session-open.bench.ts
  16. 4 4
      benchmarks/session-open/session-open.worker.ts
  17. 2 2
      docs/architecture.i18n.yaml
  18. 1 1
      docs/architecture.md
  19. 1 1
      docs/architecture.zh.md
  20. 2 2
      docs/config-catalog.i18n.yaml
  21. 1 1
      docs/config-catalog.md
  22. 1 1
      docs/config-catalog.zh.md
  23. 2 2
      docs/event-producer-consumer.i18n.yaml
  24. 4 4
      docs/event-producer-consumer.md
  25. 4 4
      docs/event-producer-consumer.zh.md
  26. 2 2
      docs/persistence-catalog.i18n.yaml
  27. 13 13
      docs/persistence-catalog.md
  28. 13 13
      docs/persistence-catalog.zh.md
  29. 2 2
      docs/subsystems/persistence.i18n.yaml
  30. 33 12
      docs/subsystems/persistence.md
  31. 33 12
      docs/subsystems/persistence.zh.md
  32. 2 2
      docs/subsystems/session.i18n.yaml
  33. 12 9
      docs/subsystems/session.md
  34. 12 9
      docs/subsystems/session.zh.md
  35. 4 1
      packages/api/session-controller/tests/test-remote.ts
  36. 3 2
      packages/core/agent-loop/src/index.ts
  37. 2 1
      packages/core/agent-loop/tests/cancel.spec.ts
  38. 1 1
      packages/core/agent-loop/tests/config-session-id.spec.ts
  39. 1 1
      packages/core/agent-loop/tests/resume.spec.ts
  40. 1 1
      packages/core/agent-loop/tests/shutdown-drain.spec.ts
  41. 55 61
      packages/core/session/src/index.ts
  42. 13 7
      packages/core/session/src/types.ts
  43. 7 5
      packages/core/session/tests/sequence-types.spec.ts
  44. 42 96
      packages/core/session/tests/session.spec.ts
  45. 2 1
      packages/experimental/agent-team/src/persisted.ts
  46. 1 1
      packages/experimental/agent-team/tests/persistence.spec.ts
  47. 1 1
      packages/experimental/agent-team/tests/team.spec.ts
  48. 3 1
      packages/experimental/agent-team/tests/test-session-query.ts
  49. 2 0
      packages/experimental/webworker-runtime/tests/vfs-example-fixture.spec.ts
  50. 13 5
      packages/extensions/tool-cordis/src/api-catalog.ts
  51. 2 2
      packages/feedback/message-feedback/src/index.ts
  52. 4 1
      packages/feedback/message-feedback/tests/helpers.ts
  53. 2 2
      packages/feedback/message-feedback/tests/loader-composition.spec.ts
  54. 1 1
      packages/llm/llm-retry/tests/persistence.spec.ts
  55. 5 1
      packages/schedule/schedule/tests/jsonl-restart.spec.ts
  56. 1 1
      packages/schedule/schedule/tests/plugin.spec.ts
  57. 1 1
      packages/session-query/session-log-export/src/archive.ts
  58. 1 1
      packages/session-query/session-log-export/tests/archive.host.spec.ts
  59. 1 1
      packages/session-query/session-log-export/tests/route.host.spec.ts
  60. 4 3
      packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
  61. 14 5
      packages/session-query/session-query/src/cold-read.ts
  62. 4 4
      packages/session-query/session-query/src/observation.ts
  63. 15 5
      packages/session-query/session-query/tests/observation.spec.ts
  64. 6 3
      packages/session-query/session-query/tests/session-query.spec.ts
  65. 3 2
      packages/session-query/session-query/tests/tracing.spec.ts
  66. 1 1
      packages/session/session-checkpoint-policy/tests/crash-recovery.e2e.ts
  67. 2 0
      packages/session/session-format-catalog/src/current.ts
  68. 2 2
      packages/session/session-format-v1-to-v2/README.i18n.yaml
  69. 1 1
      packages/session/session-format-v1-to-v2/README.md
  70. 1 1
      packages/session/session-format-v1-to-v2/README.zh.md
  71. 1 1
      packages/session/session-log-deepseek/tests/feedback-composition.spec.ts
  72. 2 2
      packages/session/session-persistence-jsonl/README.i18n.yaml
  73. 2 2
      packages/session/session-persistence-jsonl/README.md
  74. 2 2
      packages/session/session-persistence-jsonl/README.zh.md
  75. 168 239
      packages/session/session-persistence-jsonl/src/generation.ts
  76. 314 66
      packages/session/session-persistence-jsonl/src/index.ts
  77. 56 22
      packages/session/session-persistence-jsonl/src/storage.ts
  78. 1 1
      packages/session/session-persistence-jsonl/tests/built-migration-worker.e2e.ts
  79. 212 181
      packages/session/session-persistence-jsonl/tests/generation.spec.ts
  80. 320 35
      packages/session/session-persistence-jsonl/tests/jsonl.spec.ts
  81. 1 1
      packages/session/session-persistence-jsonl/tests/lease.spec.ts
  82. 2 2
      packages/session/session-persistence-jsonl/tests/lease.two-process.e2e.ts
  83. 3 1
      packages/session/session-persistence-jsonl/tests/migration-verifier.spec.ts
  84. 4 8
      packages/session/session-persistence-jsonl/tests/zstd.spec.ts
  85. 2 2
      packages/session/session-persistence/README.i18n.yaml
  86. 1 1
      packages/session/session-persistence/README.md
  87. 1 1
      packages/session/session-persistence/README.zh.md
  88. 14 3
      packages/session/session-persistence/src/handle.ts
  89. 1 0
      packages/session/session-persistence/src/index.ts
  90. 24 20
      packages/session/session-persistence/tests/contract.ts
  91. 1 1
      packages/session/session-persistence/tests/live-write-contract.ts
  92. 1 0
      packages/session/session-telemetry-otel/src/index.ts
  93. 1 1
      packages/session/session-telemetry-otel/tests/otel.spec.ts
  94. 1 1
      packages/session/session-telemetry/tests/telemetry.spec.ts
  95. 1 1
      packages/session/session-title/tests/persistence.spec.ts
  96. 5 1
      packages/subagent/subagent/tests/persistence-helpers.ts
  97. 4 1
      scripts/package-dependency-policy.ts
  98. 10 0
      scripts/type-equiv.manifest.json

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
-2026-08-10-session-log-version-mechanism.md: 98eb220e49457d3a2783edefce13d053c9b94025
-2026-08-10-session-log-version-mechanism.zh.md: 8853e6ed9a1a1ed43193cfe0949fffdbb9b4a26f
+2026-08-10-session-log-version-mechanism.md: 0f7f70b5ad6ecb2445729b1aa61fb3b295fc4ddb
+2026-08-10-session-log-version-mechanism.zh.md: a6d58505ad9fdb6068a1afe48f7250f020d20a9e

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md

@@ -14,13 +14,13 @@ Session logs must be upgradable after release, and the runtime that ships first
 
 **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.
 
-**Read rules by direction.** Equal version: read normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: every event-body operation first runs the complete adjacent chain in memory, leaves the source path, bytes, and inode unchanged, exclusively publishes only the final current generation under its canonical versioned filename, and reopens it before current restoration. Header-only listing remains non-mutating and reports the numerically highest canonical generation. Catalog generation and module initialization reject a missing adjacent step, so a published first-party build never exposes a partial historical chain. Retained lower generations are not automatic fallback or a downgrade compatibility promise.
+**Read rules by direction.** Equal version: read normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: every event-body operation first runs the complete adjacent chain in memory and leaves the source path, bytes, and inode unchanged. Read handles may consume that current logical result directly; a write open exclusively publishes the final current generation under its canonical versioned filename before append. Header-only listing remains non-mutating and reports the numerically highest canonical generation. Catalog generation and module initialization reject a missing adjacent step, so a published first-party build never exposes a partial historical chain. Retained lower generations are not automatic fallback or a downgrade compatibility promise.
 
 **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).
 
 ## Consequences
 
-What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, JSONL, and the BFF wire schema. V1 adds the static adjacent catalog, the identity v0-to-v1 edge, header-only descriptors, exact-generation JSONL publication, and current-only restoration described in [Released Session formats](2026-08-31-released-session-format-migrations.md). V2 keeps the physical codec neutral to ordinary event vocabulary and payload additions: the adjacent edge freezes its released source and target inventories, while equal-version restoration applies the installed known-event set and current payload semantics. First-party writers do not set `ignorable` through `Session.append`, while a repository-external plugin is a current consumer; equal-version retention lives in the [external-plugin retention decision](2026-08-30-retain-ignorable-external-session-events.md), and the stricter historical rule lives in the [alpha migration refusal decision](2026-08-31-alpha-historical-unknown-event-refusal.md). The unknown-type guard remains read-side because append-time vocabulary refusal would stall a live session's durability. JSONL classifies foreign versions from the minimal raw header before current-header or event parsing, so a structurally different future format reports the upgrade direction instead of "corrupt".
+What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, JSONL, and the BFF wire schema. V1 adds the static adjacent catalog, the identity v0-to-v1 edge, header-only descriptors, exact-generation JSONL publication, and current-only restoration described in [Released Session formats](2026-08-31-released-session-format-migrations.md). [Historical Session read preparation](2026-09-05-read-only-session-migration-preparation.md) owns the JSONL timing between in-memory restoration and write publication. V2 keeps the physical codec neutral to ordinary event vocabulary and payload additions: the adjacent edge freezes its released source and target inventories, while equal-version restoration applies the installed known-event set and current payload semantics. First-party writers do not set `ignorable` through `Session.append`, while a repository-external plugin is a current consumer; equal-version retention lives in the [external-plugin retention decision](2026-08-30-retain-ignorable-external-session-events.md), and the stricter historical rule lives in the [alpha migration refusal decision](2026-08-31-alpha-historical-unknown-event-refusal.md). The unknown-type guard remains read-side because append-time vocabulary refusal would stall a live session's durability. JSONL classifies foreign versions from the minimal raw header before current-header or event parsing, so a structurally different future format reports the upgrade direction instead of "corrupt".
 
 ## Alternatives considered
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md

@@ -14,13 +14,13 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决
 
 **升不升版本由写入方决定,与读取方能力无关。**当且仅当老运行时无法在语义上完全正确地处理新日志时才必须升版本。"解析不报错"不是标准:静默跳过影响重建的内容就是读错。只有结构性变更够得上这条线:header 形状、事件信封、核心事件语义、surface 机制(`SurfaceEventType` 集合、`SurfaceOp` 变体)。拿不准就升:近似恒等的升级器几乎没有成本,漏升一次会让老读取器静默读坏。
 
-**读取规则按方向区分。**版本相等:正常读。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:每个事件正文操作先在内存中运行完整相邻链,保持源路径、字节与 inode 不变,只在规范具名版本文件下排他发布最终当前 generation,再在当前恢复前重新打开。仅 header 的列表保持不变更,并报告数值最高的规范 generation。catalog 生成与模块初始化会拒绝缺失的相邻步骤,因此已发布第一方 build 绝不会暴露不完整历史链。保留的低 generation 不是自动 fallback,也不构成 downgrade compatibility 承诺。
+**读取规则按方向区分。**版本相等:正常读。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:每个事件正文操作先在内存中运行完整相邻链,并保持源路径、字节与 inode 不变。读句柄可以直接使用该 current 逻辑结果;写 open 则在 append 前把最终 current generation 排他发布到其规范版本文件名。仅 header 的列表保持不变更,并报告数值最高的规范 generation。catalog 生成与模块初始化会拒绝缺失的相邻步骤,因此已发布第一方 build 绝不会暴露不完整历史链。保留的低 generation 不是自动 fallback,也不构成 downgrade compatibility 承诺。
 
 **逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经三种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。
 
 ## 影响
 
-v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、JSONL 和 BFF 线上 schema 接受。V1 添加静态相邻 catalog、恒等 v0-to-v1 迁移边、仅 header descriptor、精确代际 JSONL 发布与[已发布 Session 格式](2026-08-31-released-session-format-migrations.zh.md)定义的当前专用恢复。V2 让物理 codec 对普通事件词汇与 payload 新增项保持中立:相邻迁移边冻结 released source 与 target 清单,同版本恢复则应用已安装的 known-event set 与当前 payload 语义。第一方 writer 不通过 `Session.append` 设置 `ignorable`,而一个仓库外插件仍依赖该字段;同版本保留由[外部插件保留决策](2026-08-30-retain-ignorable-external-session-events.zh.md)定义,更严格的历史规则由 [alpha 迁移拒绝决策](2026-08-31-alpha-historical-unknown-event-refusal.zh.md)定义。未知类型守卫仍只在读取侧生效,因为 append 时的词汇拒绝会中断活跃 Session 的持久化。JSONL 会在当前 header 或事件解析前从最小原始 header 分类外来版本,因此结构完全不同的未来格式会报告升级方向而不是"损坏"。
+v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、JSONL 和 BFF 线上 schema 接受。V1 添加静态相邻 catalog、恒等 v0-to-v1 迁移边、仅 header descriptor、精确代际 JSONL 发布与[已发布 Session 格式](2026-08-31-released-session-format-migrations.zh.md)定义的当前专用恢复。[历史 Session 只读迁移准备](2026-09-05-read-only-session-migration-preparation.zh.md)负责内存恢复与写入发布之间的 JSONL 时序。V2 让物理 codec 对普通事件词汇与 payload 新增项保持中立:相邻迁移边冻结 released source 与 target 清单,同版本恢复则应用已安装的 known-event set 与当前 payload 语义。第一方 writer 不通过 `Session.append` 设置 `ignorable`,而一个仓库外插件仍依赖该字段;同版本保留由[外部插件保留决策](2026-08-30-retain-ignorable-external-session-events.zh.md)定义,更严格的历史规则由 [alpha 迁移拒绝决策](2026-08-31-alpha-historical-unknown-event-refusal.zh.md)定义。未知类型守卫仍只在读取侧生效,因为 append 时的词汇拒绝会中断活跃 Session 的持久化。JSONL 会在当前 header 或事件解析前从最小原始 header 分类外来版本,因此结构完全不同的未来格式会报告升级方向而不是"损坏"。
 
 ## 曾考虑的替代方案
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
-2026-08-31-released-session-format-migrations.md: 3c4626c8426526a474cfba76ac820905da05beaf
-2026-08-31-released-session-format-migrations.zh.md: 806e63f2c8689476e4ca215cc6732ee835393006
+2026-08-31-released-session-format-migrations.md: 592322c0e4c1b2fa52dcf71652f3878f43a8c8ca
+2026-08-31-released-session-format-migrations.zh.md: ba2317903845739cda8da1c01c2f959c7a2ccd50

+ 5 - 16
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md

@@ -77,22 +77,9 @@ The JSONL provider scans frame boundaries once, reuses one Zstandard decoder, pa
 
 Current encoding is record based. The provider serializes about 1 MiB of plaintext per main-thread slice, streams it through one Zstandard context with source-error propagation, writes compressed output in 4 MiB batches to an exclusively created same-directory temporary file, and syncs it before publication. A process-wide scheduler admits at most two full verification Workers and hands a released permit directly to the oldest waiter.
 
-Cancellation is observed at the existing approximately 500 ms Decode yield boundary and the approximately 1 MiB encode yield boundary. A queued verifier removes its waiter when cancelled; an active verifier terminates its Worker and awaits exit before releasing the permit. This does not make the underlying file writes newly interruptible, and cancellation never rolls back a generation that has already been published.
+Preparation forwards cancellation through source reads and observes it at the existing approximately 500 ms Decode yield boundary. Once `publish()` starts, encode, Worker verification, and publication do not receive caller cancellation and run to settlement; write open checks its caller signal again afterward. A published generation is never rolled back.
 
-This decision deliberately preserves the existing serial persistence lifecycle:
-
-```text
-read/write open
-  → decode and migrate historical source
-  → encode and sync temporary current generation
-  → Worker verify
-  → recheck source
-  → publish without overwrite
-  → verify/reopen committed generation
-  → return handle
-```
-
-Read-only preparation and write publication are not separated here. Both handle kinds wait for the current generation. That scheduling problem remains independently changeable without restoring the whole-artifact format API.
+The Stage pipeline ends at one prepared current artifact. [Historical Session read preparation](2026-09-05-read-only-session-migration-preparation.md) defines how read open consumes that artifact immediately while write open performs encode, verification, and publication before returning append access.
 
 ### Durable format and publication rules
 
@@ -154,6 +141,8 @@ The current-v2 fast path remains performance-equivalent. The architectural chang
 
 ### Streaming serial migration breakdown
 
+This table records the serial open flow measured for this Stage decision. The current preparation-first scheduling and its measurements are owned by [Historical Session read preparation](2026-09-05-read-only-session-migration-preparation.md).
+
 | Phase | Median |
 |---|---:|
 | Source Decode and migration | 2.784s |
@@ -176,7 +165,7 @@ At least one final current-event array remains necessary because Session restora
 
 Decoded scalar `assistant/chunk` rows receive envelope validation and final target validation, but their complete frozen-v1 source payload-member validation is deferred because that per-event check materially affects Decode and migration time on released logs. Packed Assistant runs remain strictly decoded. The scalar check must be restored only with performance evidence that preserves this migration path's measured behavior.
 
-The serial persistence lifecycle still makes a read open wait for encode, verification, and publication. Separating logical readability from durable write readiness is a follow-up scheduling decision, not another format-pipeline rewrite.
+Read-only access consumes the Stage result before durable publication, while write open reuses the same result and waits for publication before append. The persistence scheduling remains independent from the format pipeline.
 
 Lower generations remain for operator inspection. Retention does not promise downgrade compatibility, automatic fallback, or that an older runtime can safely interpret a newer generation.
 

+ 5 - 16
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md

@@ -77,22 +77,9 @@ JSONL provider 只扫描一次 frame boundary,复用一个 Zstandard decoder
 
 Current encode 以单条 record 为单位。Provider 在主线程每个 slice 序列化约 1 MiB plaintext,通过一个会传播 source error 的 Zstandard context 流式压缩,以 4 MiB batch 写入同目录排他创建的临时文件,并在 publication 前 sync。进程级 scheduler 最多允许两个完整 verification Worker 并行,并把释放的 permit 直接交给最早的 waiter。
 
-Cancellation 会在现有的约 500 ms Decode yield 边界和约 1 MiB encode yield 边界被观察到。排队 verifier 在取消时会移除自己的 waiter;活动 verifier 会终止 Worker,并等待其退出后再释放 permit。该行为不会让底层文件写入新增可中断能力,取消也绝不会回滚已经发布的 generation。
+Preparation 会把 cancellation 传给 source read,并在现有的约 500 ms Decode yield 边界观察它。`publish()` 一旦开始,encode、Worker verification 与 publication 不接收 caller cancellation,并运行到终态;write open 会在之后再次检查 caller signal。已经发布的 generation 绝不会回滚。
 
-本决策有意保持既有串行 persistence lifecycle:
-
-```text
-read/write open
-  → decode and migrate historical source
-  → encode and sync temporary current generation
-  → Worker verify
-  → recheck source
-  → publish without overwrite
-  → verify/reopen committed generation
-  → return handle
-```
-
-这里不拆分 read-only preparation 与 write publication。两种 handle 都会等待 current generation 完成。该调度问题可以独立调整,不需要恢复 whole-artifact format API。
+Stage pipeline 终止于一份 prepared current artifact。[历史 Session 只读迁移准备](2026-09-05-read-only-session-migration-preparation.zh.md)定义 read open 如何立即消费该 artifact,以及 write open 如何在返回 append 权限前完成 encode、verification 与 publication。
 
 ### Durable format 与 publication 规则
 
@@ -154,6 +141,8 @@ Current-v2 快路径保持性能等价。架构改造不会让 current data 进
 
 ### Streaming 串行 migration 分段
 
+下表记录该 Stage 决策测量的串行 open 流程。当前 preparation-first 调度及其测量由[历史 Session 只读迁移准备](2026-09-05-read-only-session-migration-preparation.zh.md)记录。
+
 | 阶段 | 中位耗时 |
 |---|---:|
 | Source Decode + migration | 2.784s |
@@ -176,7 +165,7 @@ Format、catalog、edge、JSONL、fixture、replay 与 built-Worker 测试覆盖
 
 解码后的单条 `assistant/chunk` 会接受 envelope 校验与最终 target 校验,但其完整冻结 v1 source payload 成员校验仍处于延期状态,因为这项逐事件检查会显著影响已发布日志的 Decode 与 migration 耗时。Packed Assistant run 仍接受严格解码。只有性能证据表明不会破坏该迁移路径的已测表现时,才能恢复单条 chunk 校验。
 
-串行 persistence lifecycle 仍会让 read open 等待 encode、verification 与 publication。把逻辑 readable 与 durable writable 分开属于后续调度决策,不需要再次改写 format pipeline。
+Read-only access 会在 durable publication 前消费 Stage 结果;write open 则复用同一结果,并在 append 前等待 publication。Persistence 调度仍与 format pipeline 相互独立。
 
 低 generation 为 operator 检查而保留。Retention 不承诺 downgrade compatibility、automatic fallback,也不保证旧 runtime 能安全理解新 generation。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md
-2026-09-01-v2-embedded-assistant-streams.md: a2ed4e49e5ea19f13cba00cfd85f8d8d73dc375c
-2026-09-01-v2-embedded-assistant-streams.zh.md: b6ae4a29ff794bc6bbbd9fe1fe7c7752f16f469f
+2026-09-01-v2-embedded-assistant-streams.md: bee4d50fb830caa277bb700f3415e6d7f98ff64b
+2026-09-01-v2-embedded-assistant-streams.zh.md: 9116e94111b78af68d33f6f01dc289ee9f349b7e

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md

@@ -21,7 +21,7 @@ Session format v2 has no top-level `assistant/chunk` event. Each model attempt c
 
 `AssistantStreamAccumulator` snapshots each chunk once. Consecutive text, reasoning, or tool-argument deltas for the same block become one compact run with its first timestamp, exact timestamp gaps, and one array member per original delta. Every other chunk remains a timestamped raw record. `expandAssistantStream()` strictly validates and reconstructs the exact timed sequence; compaction never joins delta boundaries.
 
-The current v2 validator requires the embedded stream to reproduce a non-empty `assistant/message`'s content, usage, and replay state. An empty stream remains valid for a migrated legacy message that had no source chunks. `assistant/message` cannot carry obsolete chunk `sourceEventSeqs`; ordinary user and tool surface provenance remains available.
+The migration publication verifier and frozen v2 fixture validator require the embedded stream to reproduce a non-empty `assistant/message`'s content, usage, and replay state. An empty stream remains valid for a migrated legacy message that had no source chunks. Ordinary Session restoration validates the settlement fields needed by the runtime without expanding every historical stream; consumers that expand a compact stream validate its records when they read it. `assistant/message` cannot carry obsolete chunk `sourceEventSeqs`; ordinary user and tool surface provenance remains available.
 
 ### Live presentation and durable replay
 
@@ -33,7 +33,7 @@ The Client event source passes durable settlements through unchanged. The Chat a
 
 ### Released v1 to v2 migration
 
-The adjacent migration validates the complete frozen v1 artifact, groups chunks by turn, step, terminal boundary, and exact message provenance, and then substitutes one settlement per attempt. A successful group's chunks move into its message. An unclaimed group becomes `assistant/attempt` at the last consumed chunk's position. Unrelated interleaved events retain their relative order, and survivors receive dense v2 sequence numbers. The edge compacts, expands, and re-assembles embedded streams through the runtime `AssistantStreamAccumulator`, `expandAssistantStream`, and `BlockAssembler` from `dsh-llm` instead of frozen copies, because that package owns the v2 stream encoding. Target validation re-checks agreement between each migrated `assistant/message` and its embedded stream itself, so a disagreeing v1 log is refused as an unsupported migration with its source artifact retained instead of surfacing as corruption from the installed Session restoration. A later format that changes the stream encoding must freeze copies of these helpers into this edge.
+The adjacent migration validates the complete frozen v1 artifact, groups chunks by turn, step, terminal boundary, and exact message provenance, and then substitutes one settlement per attempt. A successful group's chunks move into its message. An unclaimed group becomes `assistant/attempt` at the last consumed chunk's position. Unrelated interleaved events retain their relative order, and survivors receive dense v2 sequence numbers. The edge compacts embedded streams through the runtime `AssistantStreamAccumulator` from `dsh-llm` instead of a frozen copy, because that package owns the v2 stream encoding. The isolated publication verifier expands and re-assembles the written stream through `expandAssistantStream()` and `BlockAssembler`, then checks each migrated `assistant/message` against it before publication. A later format that changes the stream encoding must freeze copies of these helpers into this edge.
 
 The edge remaps the finite declared reference inventory: envelope provenance, surface replacement endpoints, command source events, compaction ranges and shadowed lists, and title message lists. The model-visible text of a validated `session/title-llm-request` remains byte-identical in the source sequence namespace while its `messageSeqs` field moves to the v2 namespace; target validation therefore does not reconstruct that text from remapped sequences. A reference to a consumed chunk refuses migration; it is never redirected to a settlement with different meaning. The edge also refuses an inherited cut that splits an attempt.
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md

@@ -21,7 +21,7 @@ Session format v2 没有顶层 `assistant/chunk` 事件。每个模型 attempt 
 
 `AssistantStreamAccumulator` 对每个 chunk 只快照一次。同一 block 的连续 text、reasoning 或 tool argument delta 会变成一个紧凑 run,包含首个时间戳、精确时间戳间隔和每个原始 delta 对应的一个数组成员。其他 chunk 保留为带时间戳的 raw record。`expandAssistantStream()` 会严格校验并重建精确的带时间序列;压缩绝不会合并 delta 边界。
 
-当前 v2 校验器要求嵌入式 stream 能复现非空 `assistant/message` 的 content、usage 与 replay state。对于没有源 chunk 的已迁移旧 message,空 stream 仍然有效。`assistant/message` 不能携带已停用的 chunk `sourceEventSeqs`;普通 user 与 tool surface provenance 保持可用。
+Migration publication verifier 与冻结的 v2 fixture validator 要求嵌入式 stream 能复现非空 `assistant/message` 的 content、usage 与 replay state。对于没有源 chunk 的已迁移旧 message,空 stream 仍然有效。普通 Session restore 只校验 runtime 直接依赖的 settlement 字段,不展开全部历史 stream;需要展开 compact stream 的 consumer 会在读取时校验 record。`assistant/message` 不能携带已停用的 chunk `sourceEventSeqs`;普通 user 与 tool surface provenance 保持可用。
 
 ### 实时呈现与持久回放
 
@@ -33,7 +33,7 @@ Client event source 原样传递持久 settlement。Chat 与 Trajectory 的 Assi
 
 ### 已发布 v1 到 v2 迁移
 
-相邻迁移会校验完整的冻结 v1 产物,按 turn、step、terminal boundary 与精确 message provenance 对 chunk 分组,再为每个 attempt 替换一个 settlement。成功分组的 chunk 移入其 message。未被认领的分组会在最后一个被消费 chunk 的位置变成 `assistant/attempt`。无关的交错事件保持相对顺序,存活事件获得密集 v2 序号。该迁移边通过 `dsh-llm` 运行时的 `AssistantStreamAccumulator`、`expandAssistantStream` 与 `BlockAssembler` 压缩、展开并重组嵌入 stream,而不持有冻结副本,因为该包拥有 v2 stream 编码。目标校验会自行复核每个迁移后的 `assistant/message` 与其嵌入 stream 是否一致,因此不一致的 v1 日志会作为 unsupported migration 被拒绝并保留源产物,而不是由 installed Session restoration 报告为损坏。日后若某个格式改变 stream 编码,必须把这些 helper 的冻结副本纳入本迁移边。
+相邻迁移会校验完整的冻结 v1 产物,按 turn、step、terminal boundary 与精确 message provenance 对 chunk 分组,再为每个 attempt 替换一个 settlement。成功分组的 chunk 移入其 message。未被认领的分组会在最后一个被消费 chunk 的位置变成 `assistant/attempt`。无关的交错事件保持相对顺序,存活事件获得密集 v2 序号。该迁移边通过 `dsh-llm` 运行时的 `AssistantStreamAccumulator` 压缩嵌入 stream,而不持有冻结副本,因为该包拥有 v2 stream 编码。隔离的 publication verifier 通过 `expandAssistantStream()` 与 `BlockAssembler` 展开并重组写入后的 stream,并在发布前检查每个迁移后的 `assistant/message` 是否与其一致。日后若某个格式改变 stream 编码,必须把这些 helper 的冻结副本纳入本迁移边。
 
 该迁移边会重映射有限的已声明引用清单:信封 provenance、surface replacement 端点、command source event、compaction range 与 shadowed list,以及 title message list。经过校验的 `session/title-llm-request` 模型可见文本会在源序号命名空间中保持逐字节不变,而它的 `messageSeqs` 字段会迁移到 v2 命名空间;因此目标校验不会根据重映射后的序号重建该文本。指向被消费 chunk 的引用会使迁移失败;它绝不会被重定向到含义不同的 settlement。该迁移边也会拒绝切开 attempt 的继承切点。
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md
+2026-09-05-read-only-session-migration-preparation.md: c343ca457184554f4b47a0795dcb33b8b07e9d39
+2026-09-05-read-only-session-migration-preparation.zh.md: 3a283259729eda6a01fa4208cac5399f45978fac

+ 170 - 0
.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md

@@ -0,0 +1,170 @@
+# Agent Note: Historical Session reads prepare before write publication
+
+Status: implemented
+
+English | [中文](2026-09-05-read-only-session-migration-preparation.zh.md)
+
+## Problem
+
+The stateful Stage pipeline makes historical Decode and migration bounded and fast, but a serial persistence open still performs encode, sync, Worker verification, publication, and committed reopen before returning either handle kind. A read-only consumer therefore waits for about 2.2 seconds of work that it does not need and mutates storage merely to display history.
+
+### Serial readiness cost
+
+- History pagination, projection preparation, export, and the opening `session.follow` snapshot need only the validated current logical artifact.
+- A historical read open nevertheless creates and syncs a temporary v2 generation, starts a full verification Worker, rechecks the source, publishes v2, and reopens the target.
+- The migration result already exists in memory before encode, but the serial API returns only the committed physical snapshot. Persistence must Decode current bytes again to reconstruct the same logical events.
+- `session.follow` cannot deliver its opening snapshot until publication completes, even though Agent resume is the first operation that requires append access.
+- Read-only storage cannot serve a logically valid historical Session because read open requires generation publication.
+
+### A naive split would break lifecycle guarantees
+
+- Returning a write handle before verification would route append into an unpublished temporary file and create a second durability state for accepted events.
+- Starting publication automatically after every read would require backend ownership for task failure, shutdown, cleanup, and a later writer joining work it did not request.
+- A shared preparation cannot inherit the first caller's AbortSignal. One cancelled reader must not terminate work still awaited by another.
+- A read handle must initially serve prepared memory but later observe a current file and its appended tail after another caller publishes.
+- Once readers have observed one prepared artifact, source drift cannot silently rerun migration and substitute a different logical history.
+
+## Decision
+
+The JSONL backend separates logical preparation from durable publication. Read open waits only for preparation. Write open reuses a matching preparation and waits for publication before returning a writable handle.
+
+### Prepared generation API
+
+```text
+interface PreparedJsonlMigration {
+  readonly sourceIdentity: JsonlPhysicalIdentity
+  readonly artifact: SessionFormatArtifact
+  publish(): Promise<JsonlPhysicalIdentity>
+}
+```
+
+`prepareJsonlMigration()` reads one stable historical revision, runs the complete Stage chain once, and returns the current artifact without encoding or writing. `publish()` is idempotent: concurrent and later calls share one terminal Promise, including its rejection, and cannot encode the same prepared artifact twice.
+
+`publish()` streams current records into an exclusively created same-directory temporary file, syncs it, awaits the bounded Worker verifier, compares the source identity captured by preparation, and publishes the canonical path without overwrite. The successful publisher reuses the prepared logical artifact instead of decoding its target. A losing publisher verifies that the winner begins with the exact staged migration prefix; append tail validation remains a current-reader responsibility.
+
+Publication runs to settlement after invocation and is not cancelled midway by the write caller. Write open checks its caller signal before and after publication, so an abort can reject the open after the successor commits without leaking the write lease. A source identity change throws `JsonlGenerationSourceChangedError`, removes the temporary file, and does not repeat Decode or migration.
+
+### Preparation ownership and cancellation
+
+The persistence backend keeps one in-flight entry per Session id, selected source path, and stat-derived revision:
+
+```text
+interface MigrationPreparation {
+  sourcePath: string
+  sourceRevision: SessionPersistenceRevision
+  controller: AbortController
+  promise: Promise<PreparedStoredLog>
+  settled: boolean
+  waiters: number
+}
+```
+
+A new read or write open joins the existing entry only when its source path and revision still match. `waitWithAbort()` races each caller's AbortSignal against the shared Promise without forwarding that signal to shared work. The backend-owned controller is aborted only when the last waiter leaves while preparation is still running.
+
+Completed results enter the existing bounded `coldLogMemo`. The `StoredLog` discriminant separates published current state from `PreparedStoredLog`, whose `publication` field binds current logical events to their matching publication operation. A query followed by Agent resume therefore reuses the same Decode and migration result. The in-flight map owns only running work; it is not a second completed-result cache.
+
+`SessionHandle.read()` reports whether its event values are detached or shared-frozen. The JSONL backend deep-freezes each decoded event graph once before memoization and creates the `shared-frozen` result there; later reads and slices preserve that producer-established state even when the slice is empty. `readColdSessionLog()` combines those values with locally owned interrupted-turn closers and passes the `eventState` through `SessionObservationReader`; `Session.fromRestore()` validates and adopts the seed without copying or freezing. Ordinary create and fork seeds keep their defensive snapshot path.
+
+Read-only restoration validates the event and settlement fields required by Session runtime behavior but does not expand every embedded Assistant stream. The publication Worker retains complete stream replay and checks content, usage, and replay-state agreement before a migrated successor is committed. Existing current-v2 files rely on their writer; consumers that expand a compact stream validate its records when they read it.
+
+### Read handle transition
+
+A read open adopts a handle with prepared events in `state.primed` while no current generation exists. Each later `read()` resolves the current path:
+
+```text
+if current generation is absent:
+  return slice of primed events
+else:
+  clear primed events
+  read current generation and enforce non-shrinking history
+```
+
+`resolveCurrentLog()` may therefore return `undefined` for an existing historical Session: it answers whether a current canonical file exists, not whether the Session can be read. Public `stat` and `list` continue to discover the historical header.
+
+### Write-open publication
+
+Write open acquires the process-local claim and kernel-backed cross-process lease before re-resolving the selected generation. If it remains historical, it obtains or reuses the prepared `StoredLog` and awaits `publish()`. Only then does it return a write handle primed with the prepared events.
+
+```text
+write open
+  → claim process-local ownership
+  → acquire SessionWriteLease
+  → re-resolve generation
+  → join or create preparation
+  → encode + sync temp
+  → Worker verify
+  → source identity check
+  → no-overwrite publish
+  → return writable handle
+```
+
+No external caller can append before the handle exists. `append`, `flush`, and `close` therefore retain their ordinary current-generation behavior and never need a “publishing” branch. Service `flush()` continues to flush only already adopted writers; it does not turn a read-only preparation into a write.
+
+### Follow and Agent promotion
+
+`session.follow` opens history through the read path, restores the Session and projections, emits the opening snapshot, and then starts Agent promotion. Agent resume uses write open, so it waits for publication before the Agent accepts a new turn. History visibility and write readiness are separate timing points without introducing an unpublished append state.
+
+## Problem-to-solution mapping
+
+| Serial-flow problem | Implemented mechanism | Guarantee |
+|---|---|---|
+| Read-only callers wait for encode and verify | Read open returns prepared events | First content waits only for Decode and migration |
+| Concurrent historical opens repeat work | Session/source-revision keyed single-flight | One migration per selected revision |
+| First caller owns shared cancellation | Caller-local `waitWithAbort()` plus backend controller | One cancellation does not kill other waiters |
+| Preparation is lost between query and resume | `PreparedStoredLog.publication` in bounded memo | Write open reuses the same artifact |
+| No current path exists for a read handle | Primed in-memory read | Historical data is readable before publication |
+| Read handle must observe later append | Re-resolve and switch from primed data to current file | Existing handles converge after publication |
+| Append before verification is unsafe | Publish inside write open before returning the handle | Returned writer is immediately durable-ready |
+| Automatic background publication has no owner | Only write open invokes `publish()` | No orphan write task from read-only access |
+| Source changes after readers saw the artifact | Fail publication without rerunning migration | Exposed logical history is never silently replaced |
+
+## Verification
+
+The benchmark uses the same 116,228,655-byte v0 Zstandard Session as the Stage decision. The first table compares every relevant implementation; the detailed scheduling comparison then holds the Codec/Stage chain constant between #3585 and preparation-first scheduling.
+
+### First opening of historical data
+
+| Implementation | Session restored | CPU | Peak RSS | Retained heap | Result |
+|---|---:|---:|---:|---:|---|
+| Original high-performance v0 reader | 4.594s | 6.048s | 2.720GB | 2.016GB | Reads about 9.14 million v0 events without migration |
+| Master whole-artifact v0-to-v2 migration | >72.8s | — | Decode stage reached at least 7.219GB | — | OOM before returning a handle |
+| #3585 streaming migration with serial publication | 6.241s | 8.493s | 2.107GB | 477MB | Produces and publishes a 72,784-event v2 Session |
+| #3586 preparation-first scheduling | 2.954s | — | 1.026GB | 463MB | Produces the same v2 Session and defers publication until write open |
+
+Preparation-first restoration is 53% faster than #3585 and 36% faster than the original high-performance reader even though it also migrates the artifact to v2.
+
+### Scheduling observation points
+
+| User-visible point | Serial publication | Preparation-first | Change |
+|---|---:|---:|---:|
+| Read open plus Session restoration | 6.241s | 2.954s | -53% |
+| `session.follow` opening snapshot | 7.587s | 2.912s | -62% |
+| Agent receives writable Session | 6.246s | 5.161s | -17% |
+| Reopen an already-current v2 Session | 1.284s | 0.964s | -25% |
+| Follow opening-snapshot peak RSS | 2.353GB | 1.059GB | -55% |
+
+Preparation spends about 2.61 seconds in Decode and migration. Deferred publication takes about 2.56 seconds: 0.83 seconds for encode/write/sync, 1.72 seconds for strict Worker verification, and about 0.005 seconds for source check and atomic publication. A read-only request performs none of that publication work.
+
+The prepared artifact and Session restoration peak near 1.03 GB RSS. Preparation and Worker verification together peak near 2.19 GB because the parent retains the logical artifact while the Worker independently validates the physical generation.
+
+Tests cover shared-waiter cancellation, all-waiters cancellation, memo handoff, read-handle switching, source drift, winner collision, publication idempotence, write-open ordering, Worker failure, and the plain-Node bundled Worker entry.
+
+## Consequences
+
+Read-only body access does not publish a generation. The first writer pays publication once before append. A configured JSONL root must still be readable and structurally valid, but historical body migration itself does not require a successor write.
+
+The bounded memo retains one migrated event array to bridge read and write opens. This is intentional: avoiding that retained artifact would require a second Decode and migration or would prevent early read availability.
+
+Publication failure rejects Agent resume and other write opens but does not invalidate read results already delivered from the unchanged historical source. Source drift is terminal for that write attempt rather than a trigger to recompute hidden state.
+
+The backend still has a broader pre-existing lifecycle gap: dispose does not own every `create()` or `open()` operation that has not yet returned a handle. This decision does not add migration-specific tracking to `flush()` or solve that general pending-operation problem.
+
+## Alternatives considered
+
+- **Keep serial publication for every open** — is the simplest physical state model but adds about 2.2 seconds to read-only first content and requires writable storage.
+- **Publish automatically in the background after read** — needs backend task ownership, shutdown quiescence, error reporting, and writer joining even when no caller requested a write.
+- **Return a writer before verification** — requires append to an unpublished stage and creates an additional durability and failure state for accepted events.
+- **Give each caller an independent preparation** — repeats the dominant Decode and migration work and multiplies peak memory under concurrent list/follow/resume operations.
+- **Let the first caller's signal cancel shared work** — makes later callers depend on unrelated cancellation timing.
+- **Rerun migration after source drift** — can replace history already shown to readers and makes one logical operation process the same large file more than once.
+- **Always keep read handles on primed memory** — prevents an existing handle from seeing later append and diverges from ordinary persistence refresh behavior.

+ 170 - 0
.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md

@@ -0,0 +1,170 @@
+# Agent Note: 历史 Session 在写入发布前提供只读迁移结果
+
+Status: implemented
+
+[English](2026-09-05-read-only-session-migration-preparation.md) | 中文
+
+## 问题
+
+有状态 Stage pipeline 已经把历史 Decode 与 migration 恢复到有界、高性能的数据流,但串行 persistence open 仍会在返回任一种 handle 前执行 encode、sync、Worker verification、publication 与 committed reopen。只读 consumer 因此需要额外等待约 2.2 秒不需要的工作,而且仅为展示历史就会修改存储。
+
+### 串行 readable 的额外代价
+
+- 历史分页、projection preparation、export 与 `session.follow` 的 opening snapshot 只需要已经校验的 current logical artifact。
+- Historical read open 仍会创建并 sync 临时 v2 generation、启动完整 verification Worker、复查 source、发布 v2 并重新打开 target。
+- Migration 在 encode 前已经得到完整 current artifact,但串行 API 只返回 committed physical snapshot。Persistence 必须再次 Decode current bytes 才能重建相同逻辑事件。
+- `session.follow` 必须等 publication 完成后才能发出 opening snapshot,而 Agent resume 才是第一个真正要求 append 权限的操作。
+- Read-only storage 无法提供逻辑上有效的历史 Session,因为 read open 强制发布 generation。
+
+### 直接拆分会破坏 lifecycle 保证
+
+- Verify 前返回 write handle 会让 append 写入 unpublished temporary file,并为已接纳事件引入第二种 durability state。
+- 每次 read 后自动启动 publication,需要 backend 负责 task failure、shutdown、cleanup,以及后续 writer 加入一个自己没有请求的任务。
+- Shared preparation 不能继承第一个 caller 的 AbortSignal;一个 reader 取消不能终止其他 waiter 仍依赖的工作。
+- Read handle 必须先提供 prepared memory,并在其他 caller 发布后切换到 current file 与其 append tail。
+- Reader 已经观察一个 prepared artifact 后,source drift 不能悄悄重跑 migration 并替换成另一份逻辑历史。
+
+## 决策
+
+JSONL backend 将 logical preparation 与 durable publication 分开。Read open 只等待 preparation;write open 复用 matching preparation,并在返回 writable handle 前等待 publication。
+
+### Prepared generation API
+
+```text
+interface PreparedJsonlMigration {
+  readonly sourceIdentity: JsonlPhysicalIdentity
+  readonly artifact: SessionFormatArtifact
+  publish(): Promise<JsonlPhysicalIdentity>
+}
+```
+
+`prepareJsonlMigration()` 读取一个稳定 historical revision,只执行一次完整 Stage chain,并在不 encode、不写文件的情况下返回 current artifact。`publish()` 是幂等操作:并发与后续调用共享同一个终态 Promise(包括拒绝结果),不会对同一 prepared artifact 重复 encode。
+
+`publish()` 把 current records 流式写入同目录排他创建的 temporary file,执行 sync,等待 bounded Worker verifier,比较 preparation 捕获的 source identity,再通过 no-overwrite 操作发布 canonical path。成功 publisher 复用 prepared logical artifact,不重新 Decode 自己的 target。竞争失败者只验证 winner 以精确 staged migration prefix 开头;append tail validation 仍属于 current reader。
+
+Publication 调用后会运行到 settlement,不会被 write caller 中途取消。Write open 会在 publication 前后检查 caller signal,因此取消可能在后继已经提交后拒绝 open,但不会泄漏 write lease。Source identity 变化会抛出 `JsonlGenerationSourceChangedError`、删除临时文件,并且不会重复 Decode 或 migration。
+
+### Preparation ownership 与取消
+
+Persistence backend 按 Session id、selected source path 与 stat-derived revision 保存一个 in-flight entry:
+
+```text
+interface MigrationPreparation {
+  sourcePath: string
+  sourceRevision: SessionPersistenceRevision
+  controller: AbortController
+  promise: Promise<PreparedStoredLog>
+  settled: boolean
+  waiters: number
+}
+```
+
+新的 read/write open 只有在 source path 与 revision 仍匹配时才加入已有 entry。`waitWithAbort()` 让每个 caller 的 AbortSignal 与 shared Promise 竞争,但不会把 caller signal 传给共享工作。只有最后一个 waiter 在 preparation 仍运行时离开,backend-owned controller 才会 abort。
+
+完成结果进入既有 bounded `coldLogMemo`。`StoredLog` 判别字段把已发布 current state 与 `PreparedStoredLog` 分开,后者的 `publication` 字段把 current logical events 与匹配的 publication operation 绑定,使 query 后紧接的 Agent resume 复用同一次 Decode 与 migration。In-flight map 只拥有运行中的工作,不是第二个 completed-result cache。
+
+`SessionHandle.read()` 会报告 event value 是 detached 还是 shared-frozen。JSONL backend 在 memo 化前只对每个已解码 event graph 深度冻结一次,并在该处构造 `shared-frozen` 结果;后续读取和 slice 即使为空也会保留生产者建立的状态。`readColdSessionLog()` 将这些 event 与本地独占的 interrupted-turn closer 组合,并通过 `SessionObservationReader` 继续传递 `eventState`;`Session.fromRestore()` 只校验和接管 seed,不再复制或冻结。普通 create 与 fork seed 继续使用 defensive snapshot 路径。
+
+Read-only restoration 会校验 Session runtime 直接依赖的 event 与 settlement 字段,但不会展开每一段嵌入式 Assistant stream。Publication Worker 继续执行完整 stream replay,并在提交 migrated successor 前校验 content、usage 与 replay state 一致性。已有 current-v2 文件信任其 writer;需要展开 compact stream 的 consumer 会在读取时校验 record。
+
+### Read handle 切换
+
+Current generation 不存在时,read open 会采用在 `state.primed` 中保存 prepared events 的 handle。后续每次 `read()` 都重新解析 current path:
+
+```text
+if current generation is absent:
+  return slice of primed events
+else:
+  clear primed events
+  read current generation and enforce non-shrinking history
+```
+
+因此,一个已有 historical Session 也可能让 `resolveCurrentLog()` 返回 `undefined`:它回答的是 current canonical file 是否存在,而不是 Session 是否可读。公开 `stat` 与 `list` 继续发现 historical header。
+
+### Write-open publication
+
+Write open 先取得进程内 claim 与内核支持的跨进程 lease,再重新解析 selected generation。如果它仍是 historical,就取得或复用 prepared `StoredLog` 并等待 `publish()`。之后才返回以 prepared events 为 primed state 的 write handle。
+
+```text
+write open
+  → claim process-local ownership
+  → acquire SessionWriteLease
+  → re-resolve generation
+  → join or create preparation
+  → encode + sync temp
+  → Worker verify
+  → source identity check
+  → no-overwrite publish
+  → return writable handle
+```
+
+Handle 返回前,外部 caller 无法 append。因此 `append`、`flush` 与 `close` 保持普通 current-generation 行为,不需要“publishing”分支。Service `flush()` 继续只 flush 已经 adopt 的 writer;它不会把 read-only preparation 转成 write。
+
+### Follow 与 Agent promotion
+
+`session.follow` 通过 read path 打开历史、恢复 Session 与 projections、发出 opening snapshot,然后启动 Agent promotion。Agent resume 使用 write open,因此会在 Agent 接收新一轮对话前等待 publication。历史可见与写入就绪成为两个明确时间点,同时不引入 unpublished append state。
+
+## 问题与方案对照
+
+| 串行流程问题 | 实现机制 | 保证 |
+|---|---|---|
+| Read-only caller 等待 encode 与 verify | Read open 返回 prepared events | 首屏只等待 Decode + migration |
+| 并发 historical open 重复工作 | Session/source-revision keyed single-flight | 每个 selected revision 只迁移一次 |
+| 第一个 caller 拥有共享取消 | Caller-local `waitWithAbort()` + backend controller | 单个取消不终止其他 waiter |
+| Query 与 resume 之间丢失 preparation | Bounded memo 中的 `PreparedStoredLog.publication` | Write open 复用相同 artifact |
+| Read handle 没有 current path | Primed in-memory read | Publication 前 historical data 可读 |
+| Read handle 需要观察后续 append | 重新 resolve,并从 primed data 切到 current file | Publication 后已有 handle 收敛 |
+| Verify 前 append 不安全 | Write open 返回前完成 publication | 返回 writer 立即具备普通 durability |
+| 自动后台 publication 无 owner | 只有 write open 调用 `publish()` | Read-only access 不产生 orphan write task |
+| Reader 已看到 artifact 后 source 改变 | Publication 失败且不重跑 migration | 已暴露逻辑历史不被静默替换 |
+
+## 验证
+
+Benchmark 使用 Stage 决策中的同一份 116,228,655-byte v0 Zstandard Session。第一张表比较 migration 工作涉及的全部实现;后续调度明细则保持 #3585 与 preparation-first 使用同一条 Codec/Stage chain,仅改变 persistence 调度。
+
+### 用户首次打开历史数据
+
+| 实现 | Session restore | CPU | Peak RSS | Retained heap | 结果 |
+|---|---:|---:|---:|---:|---|
+| 原高性能 v0 reader | 4.594s | 6.048s | 2.720GB | 2.016GB | 不迁移,读取约 914 万个 v0 event |
+| Master whole-artifact v0-to-v2 migration | >72.8s | — | Decode 阶段达到至少 7.219GB | — | 返回 handle 前 OOM |
+| #3585 streaming migration + 串行 publication | 6.241s | 8.493s | 2.107GB | 477MB | 生成并发布包含 72,784 个 event 的 v2 Session |
+| #3586 preparation-first 调度 | 2.954s | — | 1.026GB | 463MB | 生成相同 v2 Session,并把 publication 延迟到 write open |
+
+Preparation-first restore 比 #3585 快 53%,也比原高性能 reader 快 36%,同时仍然完成 artifact 到 v2 的 migration。
+
+### 调度观测点
+
+| 用户观测点 | 串行 publication | Preparation-first | 变化 |
+|---|---:|---:|---:|
+| Read open + Session restore | 6.241s | 2.954s | -53% |
+| `session.follow` opening snapshot | 7.587s | 2.912s | -62% |
+| Agent 得到 writable Session | 6.246s | 5.161s | -17% |
+| 已是 current v2 的再次打开 | 1.284s | 0.964s | -25% |
+| Follow opening-snapshot peak RSS | 2.353GB | 1.059GB | -55% |
+
+Preparation 中约 2.61 秒用于 Decode 与 migration。延后的 publication 约为 2.56 秒:encode/write/sync 0.83 秒、严格 Worker verification 1.72 秒、source check 与 atomic publication 约 0.005 秒。Read-only 请求完全不执行这段 publication。
+
+Prepared artifact 与 Session restore 的 peak RSS 约为 1.03 GB。Preparation 与 Worker verification 同时存在时峰值约 2.19 GB,因为 parent 保留 logical artifact,而 Worker 独立校验 physical generation。
+
+测试覆盖 shared-waiter cancellation、all-waiter cancellation、memo handoff、read-handle switching、source drift、winner collision、publication idempotence、write-open ordering、Worker failure 与 plain-Node bundled Worker entry。
+
+## 后果
+
+Read-only body access 不发布 generation。第一个 writer 会在 append 前支付一次 publication。已配置的 JSONL root 仍必须可读且结构有效,但 historical body migration 本身不要求写 successor。
+
+Bounded memo 会保留一份 migrated event array,用于连接 read 与 write open。这是有意的取舍:不保留该 artifact 就必须重复 Decode 与 migration,或者无法提前提供 read。
+
+Publication failure 会拒绝 Agent resume 和其他 write open,但不会使已经从 unchanged historical source 交付的 read result 失效。Source drift 对该 write attempt 是 terminal failure,不会触发 hidden state 重算。
+
+Backend 仍存在一个更广泛的既有 lifecycle 缺口:dispose 不拥有每个尚未返回 handle 的 `create()` 或 `open()` operation。本决策不会向 `flush()` 增加 migration-specific tracking,也不解决通用 pending-operation 问题。
+
+## 考虑过的替代方案
+
+- **每个 open 都保持串行 publication**——physical state 最简单,但让 read-only 首屏多等待约 2.2 秒并要求存储可写。
+- **Read 后自动后台 publish**——需要 backend task ownership、shutdown quiescence、error reporting,以及 writer 加入一个没有 caller 请求的任务。
+- **Verify 前返回 writer**——要求 append 写入 unpublished stage,并为已接纳事件增加一种 durability 与 failure state。
+- **每个 caller 独立 preparation**——重复最重的 Decode 与 migration,并在 list/follow/resume 并发时放大峰值内存。
+- **让第一个 caller signal 取消共享工作**——使后续 caller 依赖无关的 cancellation timing。
+- **Source drift 后重跑 migration**——可能替换已经展示给 reader 的历史,也会让一次逻辑 operation 重复处理同一大文件。
+- **Read handle 永远停留在 primed memory**——无法观察后续 append,并偏离普通 persistence refresh 行为。

+ 1 - 1
apps/web/tests/scaffold.ts

@@ -1329,7 +1329,7 @@ async function persistSeedSession(
 export async function readPersistedEvents(scaffold: WebScaffold, id: SessionId): Promise<readonly SessionEvent[]> {
   const handle = await scaffold.ctx.sessionPersistence.open(id, 'read')
   try {
-    return await handle.read()
+    return (await handle.read()).events
   } finally {
     await handle.close()
   }

+ 1 - 1
apps/web/tests/schedule-after.e2e.ts

@@ -615,7 +615,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
     // Seed the zero-I/O list view before the Session is opened.
     const catalogReader = await scaffold.ctx.sessionPersistence.open(CATALOG_SESSION_ID, 'read')
     try {
-      const catalogEvents = [...await catalogReader.read()]
+      const catalogEvents = [...(await catalogReader.read()).events]
       scaffold.ctx.sessionProjectionCache.coldSnapshot(catalogReader.header, catalogReader.inheritedEventCount, catalogEvents)
     } finally {
       await catalogReader.close()

+ 1 - 1
benchmarks/session-open/session-open.bench.ts

@@ -173,7 +173,7 @@ class SessionOpenBenchmarkSuite {
     this.legacySourcePath = this.facts.path
     // Produce one real post-upgrade directory outside every measured interval.
     const templateRoot = await this.createRoot('first-open', 'post-upgrade-template')
-    requireReport(await runWorker(templateRoot, 'phase-migrate'), 'phase-migrate')
+    requireReport(await runWorker(templateRoot, 'agent-resume'), 'agent-resume')
     this.currentSourcePath = join(
       templateRoot,
       SYNTHETIC_SESSION_DIRECTORY,

+ 4 - 4
benchmarks/session-open/session-open.worker.ts

@@ -231,17 +231,17 @@ class SessionBenchmarkHost {
     const handle = await this.ctx.sessionPersistence.open(SessionId(SYNTHETIC_SESSION_ID), 'read')
     const openMs = performance.now() - phaseStarted
     phaseStarted = performance.now()
-    const persisted = await handle.read()
+    const read = await handle.read()
     await handle.close()
     const readMs = performance.now() - phaseStarted
     phaseStarted = performance.now()
-    const repaired = [...persisted, ...interruptedTurnClosers(persisted)]
-    const seed = repaired.map(event => structuredClone(event))
+    const repaired = [...read.events, ...interruptedTurnClosers(read.events)]
+    const seed = repaired
     const preparation = SessionPreparation.create(this.ctx.sessions.prepare(SessionId(SYNTHETIC_SESSION_ID), {
       seed,
       meta: structuredClone(handle.header),
       inheritedEventCount: handle.inheritedEventCount,
-      seedSource: 'persistence',
+      eventState: read.eventState,
     }))
     this.preparation = preparation
     const sessionRestoreMs = performance.now() - phaseStarted

+ 2 - 2
docs/architecture.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/architecture.md
-architecture.md: 76dda23c8a2200892587687417e326dc7cd15d80
-architecture.zh.md: 4e81625a0bba2698fc286d1ab0520fe4ec56a469
+architecture.md: bbd6a7e09b6af2fe5e90acab33ad220d3f1b62d1
+architecture.zh.md: b05670f8f715c5c3dd5c8cdd76ec81e9d426ace6

+ 1 - 1
docs/architecture.md

@@ -106,7 +106,7 @@ Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-ex
 
 The session log is the source of the context the model sees. `deriveMessages()` projects model history from it. Each `assistant/message` embeds the exact compact timed stream that produced its assembled content; `assistant/attempt` retains settled failed, retried, cancelled, and stream-error attempts without adding model history. Fork, resume, transcripts, telemetry, and persistence all derive from these durable settlements, while live UI incrementality comes from `agent/assistant-stream`; a hard process loss before settlement leaves no durable attempt stream ([decision](../.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md)).
 
-Session consumers know only the current logical format. Header-only `stat` and `list` rescan each Session directory, select its numerically highest canonical generation, and translate a supported historical header without loading events or publishing a successor. A stored-session `open` selects that same generation, refuses a future version, or composes the static adjacent migration chain in memory, validates the final result, and exclusively publishes only that version-named successor beside the unchanged source before returning a handle. Ordinary repair of an unsealed interrupted tail remains a handle consumer responsibility; migration inserts a missing interrupted `turn/end` only for the bounded released restart already sealed by a later `turn/start`. JSONL v0 uses `session.jsonl[.zstd]`, v1 and later use lowercase `session.vN.jsonl[.zstd]`, and committed generation paths are never renamed, replaced, or deleted. The JSONL provider owns physical framing, compression, generation selection, and exclusive publication, while each adjacent migration package owns exactly one `vN -> vN+1` step ([decision](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)).
+Session consumers know only the current logical format. Header-only `stat` and `list` rescan each Session directory, select its numerically highest canonical generation, and translate a supported historical header without loading events or publishing a successor. A stored-session `open` selects that same generation, refuses a future version, or decodes and composes the static adjacent migration chain once before returning validated current logical events. A read open uses that in-memory result without publishing a successor; a write open first encodes, verifies, and exclusively publishes the final version-named successor beside the unchanged source. Ordinary repair of an unsealed interrupted tail remains a handle consumer responsibility; migration inserts a missing interrupted `turn/end` only for the bounded released restart already sealed by a later `turn/start`. JSONL v0 uses `session.jsonl[.zstd]`, v1 and later use lowercase `session.vN.jsonl[.zstd]`, and committed generation paths are never renamed, replaced, or deleted. The JSONL provider owns physical framing, compression, generation selection, and exclusive publication, while each adjacent migration package owns exactly one `vN -> vN+1` step ([decision](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)).
 
 **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.
 

+ 1 - 1
docs/architecture.zh.md

@@ -110,7 +110,7 @@ turn/end
 
 会话日志是模型所见上下文的来源。`deriveMessages()` 从中投影出模型历史。每个 `assistant/message` 都嵌入产生其组装内容的精确紧凑带时间 stream;`assistant/attempt` 保留已到达 settlement 的失败、重试、取消与 stream error attempt,且不添加模型历史。fork、恢复、transcript(文本记录)、遥测与持久化都从这些持久 settlement 派生,实时 UI 增量则来自 `agent/assistant-stream`;如果进程在 settlement 前硬中断,则不会留下持久 attempt stream(见[决策](../.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md))。
 
-Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list` 会重新扫描每个 Session 目录,选择数值最高的规范 generation,并在不加载事件或发布后继的情况下转换受支持的历史 header。已存储 Session 的 `open` 选择同一 generation,拒绝未来版本,或在内存中组合静态相邻迁移链、校验最终结果,并在返回句柄前以不覆盖方式只发布该版本命名的后继文件且保持源文件不变。未被后续事件封住的普通中断尾部仍由句柄消费方修复;只有在后续 `turn/start` 已经封住一种有限的已发布 restart 时,migration 才会插入缺失的 interrupted `turn/end`。JSONL v0 使用 `session.jsonl[.zstd]`,v1 及后续版本使用小写 `session.vN.jsonl[.zstd]`;已提交 generation 路径绝不重命名、替换或删除。JSONL provider 负责物理 framing、压缩、generation 选择与排他发布,每个相邻迁移包只负责一个 `vN -> vN+1` 步骤([决策](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。
+Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list` 会重新扫描每个 Session 目录,选择数值最高的规范 generation,并在不加载事件或发布后继的情况下转换受支持的历史 header。已存储 Session 的 `open` 选择同一 generation,拒绝未来版本,或只 Decode 并组合一次构建时静态确定的相邻迁移链,再返回经过校验的当前逻辑事件。只读 open 直接使用这份内存结果,不发布后继;写 open 则先编码、校验并在未改变源的旁边排他发布最终版本命名的后继。未被后续事件封住的普通中断尾部仍由句柄消费方修复;只有在后续 `turn/start` 已经封住一种有限的已发布 restart 时,migration 才会插入缺失的 interrupted `turn/end`。JSONL v0 使用 `session.jsonl[.zstd]`,v1 及后续版本使用小写 `session.vN.jsonl[.zstd]`;已提交 generation 路径绝不重命名、替换或删除。JSONL provider 负责物理 framing、压缩、generation 选择与排他发布,每个相邻迁移包只负责一个 `vN -> vN+1` 步骤([决策](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。
 
 **模型可见即已记录。** 抵达模型请求的一切都必须能从日志重建,并由一项运行时不变量断言这一点。因此,新增一项模型可见输入就需要新增一个会话事件:扩展 `SessionEventMap` 并从日志渲染。
 

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: df4b4e4c5fde61ca3f2a97182463e196aa9e40b0
-config-catalog.zh.md: f7c4adf580cf11f05fbe209cf0ffeb4dfb50b45a
+config-catalog.md: b6faf8f2bc31d3243c02824677690bd75fda7290
+config-catalog.zh.md: 9ee64492366a0da009fe47e6811f98a45ff7f5ff

+ 1 - 1
docs/config-catalog.md

@@ -1870,7 +1870,7 @@ export interface Config {
 export type JsonlCompression = 'zstd' | 'none'
 ```
 
-Source: [`packages/session/session-persistence-jsonl/src/index.ts:87`](../packages/session/session-persistence-jsonl/src/index.ts)
+Source: [`packages/session/session-persistence-jsonl/src/index.ts:88`](../packages/session/session-persistence-jsonl/src/index.ts)
 
 <a id="deepseek-aidsh-session-projection-cache"></a>
 

+ 1 - 1
docs/config-catalog.zh.md

@@ -1872,7 +1872,7 @@ export interface Config {
 export type JsonlCompression = 'zstd' | 'none'
 ```
 
-来源:[`packages/session/session-persistence-jsonl/src/index.ts:85`](../packages/session/session-persistence-jsonl/src/index.ts)
+来源:[`packages/session/session-persistence-jsonl/src/index.ts:88`](../packages/session/session-persistence-jsonl/src/index.ts)
 
 <a id="deepseek-aidsh-session-projection-cache"></a>
 

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: da072c8d2c12aa768bc1c357e6dea37821116d66
-event-producer-consumer.zh.md: af0a2a63c5dfeb391b2e82d2b6e972d69a9e8864
+event-producer-consumer.md: 3ee601aaef182a68e2ee24fd3bdf42f7240c2a22
+event-producer-consumer.zh.md: 3d0f003cb0639d0930f2d87a95f13321c0be9f71

+ 4 - 4
docs/event-producer-consumer.md

@@ -47,10 +47,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:51`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
-| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:72`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
 | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |

+ 4 - 4
docs/event-producer-consumer.zh.md

@@ -49,10 +49,10 @@
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:51`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
-| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:72`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
 | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |

+ 2 - 2
docs/persistence-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/persistence-catalog.md
-persistence-catalog.md: 1111d72573fffb290929361fc48320fbe47bb497
-persistence-catalog.zh.md: 80cc19441eb151a28ade4110bd364e3efcbed384
+persistence-catalog.md: c47cd8a09d4c7a5179bb36d9c62611564b95a5cc
+persistence-catalog.zh.md: c67d315c91565cee5855ee5b0667672151b83323

+ 13 - 13
docs/persistence-catalog.md

@@ -88,7 +88,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 }[T]
 ```
 
-Sources: [`packages/core/session/src/types.ts:379`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:387`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:416`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:447`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:385`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:393`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:422`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:453`](../packages/core/session/src/types.ts)
 
 ## Events
 
@@ -215,7 +215,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:33`](../packages/inter
 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] }
 ```
 
-Source: [`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts)
 
 <a id="assistantmessage--surface"></a>
 
@@ -245,7 +245,7 @@ Source: [`packages/core/session/src/types.ts:313`](../packages/core/session/src/
 
 Types: [TokenUsage](subsystems/llm-streaming.md)
 
-Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts)
 
 ### `command/*`
 
@@ -593,7 +593,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:46`](../packages/plan/plan-mode/s
 'request/context': RequestContext
 ```
 
-Source: [`packages/core/session/src/types.ts:352`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:358`](../packages/core/session/src/types.ts)
 
 <a id="requestheader--log-only"></a>
 
@@ -612,7 +612,7 @@ Source: [`packages/core/session/src/types.ts:352`](../packages/core/session/src/
 }
 ```
 
-Source: [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:348`](../packages/core/session/src/types.ts)
 
 ### `sandbox/*`
 
@@ -687,7 +687,7 @@ Source: [`packages/schedule/schedule/src/types.ts:219`](../packages/schedule/sch
 'session/end-seed': { inherited?: true }
 ```
 
-Source: [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:381`](../packages/core/session/src/types.ts)
 
 <a id="sessiontitle--log-only"></a>
 
@@ -749,7 +749,7 @@ Source: [`packages/session/session-log-deepseek/src/types.ts:59`](../packages/se
 'step/end': { turn: number; step: number }
 ```
 
-Source: [`packages/core/session/src/types.ts:280`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts)
 
 <a id="stepstart--log-only"></a>
 
@@ -760,7 +760,7 @@ Source: [`packages/core/session/src/types.ts:280`](../packages/core/session/src/
 'step/start': { turn: number; step: number }
 ```
 
-Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts)
 
 ### `subagent/*`
 
@@ -891,7 +891,7 @@ Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/s
 
 Types: [ToolCallId](subsystems/core.md)
 
-Source: [`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:325`](../packages/core/session/src/types.ts)
 
 <a id="toolcode-dispatch--log-only"></a>
 
@@ -966,7 +966,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types
 }
 ```
 
-Source: [`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:337`](../packages/core/session/src/types.ts)
 
 ### `tool-workflow/*`
 
@@ -1046,7 +1046,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow
 
 Types: [TurnEndReason](subsystems/session.md)
 
-Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts)
 
 <a id="turnstart--log-only"></a>
 
@@ -1062,7 +1062,7 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/
 'turn/start': { turn: number }
 ```
 
-Source: [`packages/core/session/src/types.ts:267`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts)
 
 ### `user/*`
 
@@ -1081,7 +1081,7 @@ Source: [`packages/core/session/src/types.ts:267`](../packages/core/session/src/
 'user/message': UserMessage
 ```
 
-Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts)
 
 ### `web/*`
 

+ 13 - 13
docs/persistence-catalog.zh.md

@@ -90,7 +90,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 }[T]
 ```
 
-来源:[`packages/core/session/src/types.ts:379`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:387`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:416`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:447`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:385`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:393`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:422`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:453`](../packages/core/session/src/types.ts)
 
 ## 事件
 
@@ -217,7 +217,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] }
 ```
 
-来源:[`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts)
 
 <a id="assistantmessage--surface"></a>
 
@@ -247,7 +247,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 类型:[TokenUsage](subsystems/llm-streaming.zh.md)
 
-来源:[`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts)
 
 ### `command/*`
 
@@ -595,7 +595,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'request/context': RequestContext
 ```
 
-来源:[`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:358`](../packages/core/session/src/types.ts)
 
 <a id="requestheader--log-only"></a>
 
@@ -614,7 +614,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 }
 ```
 
-来源:[`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:348`](../packages/core/session/src/types.ts)
 
 ### `sandbox/*`
 
@@ -689,7 +689,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'session/end-seed': { inherited?: true }
 ```
 
-来源:[`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:381`](../packages/core/session/src/types.ts)
 
 <a id="sessiontitle--log-only"></a>
 
@@ -751,7 +751,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'step/end': { turn: number; step: number }
 ```
 
-来源:[`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts)
 
 <a id="stepstart--log-only"></a>
 
@@ -762,7 +762,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'step/start': { turn: number; step: number }
 ```
 
-来源:[`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts)
 
 ### `subagent/*`
 
@@ -893,7 +893,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 类型:[ToolCallId](subsystems/core.zh.md)
 
-来源:[`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:325`](../packages/core/session/src/types.ts)
 
 <a id="toolcode-dispatch--log-only"></a>
 
@@ -968,7 +968,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 }
 ```
 
-来源:[`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:337`](../packages/core/session/src/types.ts)
 
 ### `tool-workflow/*`
 
@@ -1048,7 +1048,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 类型:[TurnEndReason](subsystems/session.zh.md)
 
-来源:[`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts)
 
 <a id="turnstart--log-only"></a>
 
@@ -1064,7 +1064,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'turn/start': { turn: number }
 ```
 
-来源:[`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts)
 
 ### `user/*`
 
@@ -1083,7 +1083,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'user/message': UserMessage
 ```
 
-来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts)
 
 ### `web/*`
 

+ 2 - 2
docs/subsystems/persistence.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/persistence.md
-persistence.md: 8667e3680499cebe38aefd249f38fdd7e035e414
-persistence.zh.md: af181d53a1b6bef737c96c829cd9ac551ff246ab
+persistence.md: 8ba4e74768050646df4904d4ae1a978781685f35
+persistence.zh.md: 1821b07df7686a7aa77cfb5c837f903e199ccf94

+ 33 - 12
docs/subsystems/persistence.md

@@ -8,7 +8,20 @@ The seam is a [capability seam](../../.agents/notes/implemented/architecture/202
 
 ## `SessionHandle` — one open channel onto a stored session
 
-Every log read and write flows through a handle, never through id-addressed service methods: the handle is the single door the cross-process write lease guards. One handle type serves both accesses — a mutation on a `read` handle is a runtime `SessionReadOnlyError` rather than a typed split — and in-process single-writer ownership makes a second `open(id, 'write')` reject with `SessionAlreadyOwnedError` while an owner is active.
+Every log read and write flows through a handle, never through id-addressed service methods: the handle is the single door the cross-process write lease guards. A read returns a caller-owned outer slice and the producer-established aliasing state of its event values. One handle type serves both accesses — a mutation on a `read` handle is a runtime `SessionReadOnlyError` rather than a typed split — and in-process single-writer ownership makes a second `open(id, 'write')` reject with `SessionAlreadyOwnedError` while an owner is active.
+
+```ts type-equiv
+/** One persistence event slice returned by {@link SessionHandle.read}. */
+interface SessionHandleReadResult {
+  /**
+   * Whether event values are exclusively owned or shared only after deep
+   * freezing. Slicing preserves the producer's state even when no events remain.
+   */
+  readonly eventState: SessionSeedEventState
+  /** Event values in a caller-owned outer array. */
+  readonly events: readonly SessionEvent[]
+}
+```
 
 ```ts type-equiv
 /**
@@ -47,9 +60,9 @@ interface SessionHandle extends AsyncDisposable {
    * @param length - maximum number of events to return; defaults to the rest
    *   of the log. An offset at or past the end returns an empty list.
    * @param options - optional cancellation.
-   * @returns the events with `seq >= offset`, at most `length` of them.
+   * @returns the caller-owned outer slice plus the ownership state of its event values.
    */
-  read(offset?: number, length?: number, options?: SessionHandleReadOptions): Promise<readonly SessionEvent[]>
+  read(offset?: number, length?: number, options?: SessionHandleReadOptions): Promise<SessionHandleReadResult>
 
   /**
    * Append a contiguous batch continuing the current logical end. The first
@@ -173,7 +186,7 @@ interface SessionHeader {
 
 ## Format refusal — logs a build cannot faithfully read
 
-A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. `stat` and `list` classify the highest canonical generation and translate a supported historical header without reading or mutating its body. `open` runs the build-static adjacent migration chain under per-id serialization before returning a handle, leaves every source path, byte, and inode unchanged, and exclusively publishes only the final current generation. A future highest generation refuses even when an older readable generation remains. Current v2 restoration retains installed extensions and unknown events carrying `ignorable: true`; historical v0/v1 migration refuses an unknown type even when marked ignorable. The message appends the selected raw log path when the backend keeps one artifact per session. The JSONL backend migrates released v0 or v1 to current v2 and refuses a future version before interpreting its version-specific fields or event rows. An out-of-tree backend must enforce equivalent current-only handle values and direction-aware refusals at its physical-format entry. The [released-format migration decision](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) owns the chain and immutable-publication rules.
+A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. `stat` and `list` classify the highest canonical generation and translate a supported historical header without reading or mutating its body. Historical `open` calls share one per-session migration preparation before returning current logical values and leave every source path, byte, and inode unchanged. The JSONL provider returns a read handle from that in-memory result without publishing; a write open holds its single-writer claim and file lease while it reuses the preparation, exclusively publishes the final current generation, and only then returns the writable handle. A future highest generation refuses even when an older readable generation remains. Current v2 restoration retains installed extensions and unknown events carrying `ignorable: true`; historical v0/v1 migration refuses an unknown type even when marked ignorable. The message appends the selected raw log path when the backend keeps one artifact per session. An out-of-tree backend must enforce equivalent current-only handle values and direction-aware refusals at its physical-format entry. The [released-format migration decision](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) owns the chain and immutable-publication rules.
 
 ## `CreateSessionOptions` — seeding and metadata
 
@@ -214,29 +227,37 @@ Replay/fork is therefore `ctx.agents.create({ sessionId, seed, meta })` — a fo
 
 ## Preparation and restoration ownership
 
-`SessionStore.prepare()` accepts ordinary creation options or fresh persistence graphs transferred through `RestoredSessionOptions`. The restoration branch validates and freezes the transferred header and events in place, so callers must retain no mutable aliases. `SessionPreparation` then owns the exact unpublished Session until publication or rollback; disposal is synchronous and idempotent. agent-loop's resume builds these graphs by reading the stored log through the session's write handle and appending any needed `interruptedTurnClosers` before preparation.
+`SessionStore.prepare()` accepts ordinary creation options or an adoptable seed through `RestoredSessionOptions`. Its `eventState` says whether event values are independently owned or shared only after deep freezing; the producer establishes that state, and slicing does not infer a different state from result length. Restoration validates and adopts those values without another copy or freeze pass. `SessionPreparation` then owns the exact unpublished Session until publication or rollback; disposal is synchronous and idempotent. agent-loop's resume reads this result through the session's write handle and appends independently owned `interruptedTurnClosers` before preparation.
+
+```ts type-equiv
+/**
+ * Aliasing state of an adoptable Session seed. `shared-frozen` permits deeply
+ * frozen aliases plus independently owned unfrozen values in the same seed.
+ */
+type SessionSeedEventState = 'detached' | 'shared-frozen'
+```
 
 ```ts type-equiv
 /**
- * Fresh storage values transferred to {@link SessionStore.prepare} without a
- * second serialization copy. Callers retain no mutable aliases.
+ * Adoptable storage values transferred to {@link SessionStore.prepare}
+ * without another copy or freeze pass.
  */
 interface RestoredSessionOptions {
-  /** Fresh detached storage events to validate and freeze in place. */
+  /** Events that are independently owned or already deeply frozen. */
   readonly seed: SessionEvent[]
-  /** Fresh detached storage metadata to validate and freeze in place. */
+  /** Independently owned storage metadata to validate and freeze in place. */
   readonly meta: SessionHeader
   /** Exact number of fork-inherited leading events decoded from storage. */
   readonly inheritedEventCount: SessionLogOffset
-  /** Select the persistence ownership-transfer path. */
-  readonly seedSource: 'persistence'
+  /** Aliasing state carried from the operation that produced the seed. */
+  readonly eventState: SessionSeedEventState
 }
 ```
 
 ```ts type-equiv
 /** Inputs accepted while constructing an unpublished Session. */
 type PrepareSessionOptions =
-  | (CreateSessionOptions & { readonly seedSource?: undefined })
+  | (CreateSessionOptions & { readonly eventState?: undefined })
   | RestoredSessionOptions
 ```
 

+ 33 - 12
docs/subsystems/persistence.zh.md

@@ -8,7 +8,20 @@
 
 ## `SessionHandle`——通向已存储会话的一条打开通道
 
-每一次日志读写都经由句柄流动,绝不经由按 id 寻址的服务方法:句柄是跨进程写租约把守的那扇唯一的门。一种句柄类型同时服务两种访问——在 `read` 句柄上执行修改是运行时的 `SessionReadOnlyError`,而非类型层面的拆分——而进程内单写者所有权使得在已有活跃持有者时第二次 `open(id, 'write')` 以 `SessionAlreadyOwnedError` 拒绝。
+每一次日志读写都经由句柄流动,绝不经由按 id 寻址的服务方法:句柄是跨进程写租约把守的唯一入口。读取会返回调用方独占的外层 slice,以及由生产者建立的 event value 别名状态。一种句柄类型同时服务两种访问——在 `read` 句柄上执行修改是运行时的 `SessionReadOnlyError`,而非类型层面的拆分——而进程内单写者所有权使得在已有活跃持有者时第二次 `open(id, 'write')` 以 `SessionAlreadyOwnedError` 拒绝。
+
+```ts type-equiv
+/** One persistence event slice returned by {@link SessionHandle.read}. */
+interface SessionHandleReadResult {
+  /**
+   * Whether event values are exclusively owned or shared only after deep
+   * freezing. Slicing preserves the producer's state even when no events remain.
+   */
+  readonly eventState: SessionSeedEventState
+  /** Event values in a caller-owned outer array. */
+  readonly events: readonly SessionEvent[]
+}
+```
 
 ```ts type-equiv
 /**
@@ -47,9 +60,9 @@ interface SessionHandle extends AsyncDisposable {
    * @param length - maximum number of events to return; defaults to the rest
    *   of the log. An offset at or past the end returns an empty list.
    * @param options - optional cancellation.
-   * @returns the events with `seq >= offset`, at most `length` of them.
+   * @returns the caller-owned outer slice plus the ownership state of its event values.
    */
-  read(offset?: number, length?: number, options?: SessionHandleReadOptions): Promise<readonly SessionEvent[]>
+  read(offset?: number, length?: number, options?: SessionHandleReadOptions): Promise<SessionHandleReadResult>
 
   /**
    * Append a contiguous batch continuing the current logical end. The first
@@ -173,7 +186,7 @@ interface SessionHeader {
 
 ## 格式拒绝:本构建无法可靠读取的日志
 
-后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。`stat` 与 `list` 会对最高规范 generation 分类,并在不读取或改变正文的前提下转换受支持的历史 header。`open` 会在按 id 串行化的区段内运行构建时静态确定的相邻迁移链,再返回句柄;每个源路径、字节与 inode 都保持不变,并且只排他发布最终的当前 generation。即使仍有较旧的可读 generation,最高的未来 generation 仍会导致拒绝。当前 v2 恢复会保留已安装扩展和带 `ignorable: true` 的未知事件;历史 v0/v1 迁移则会拒绝未知类型,即使它带有 ignorable 标记。后端为每个会话保留独立文件时,消息附上选定的原始日志路径。JSONL 后端把已发布 v0 或 v1 迁移到当前 v2,并在解读其版本专属字段或事件行前拒绝未来版本。仓库外后端必须在自己的物理格式入口提供等价的仅当前句柄值与方向感知拒绝。[已发布格式迁移决策](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)负责迁移链与不可变发布规则。
+后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。`stat` 与 `list` 会对最高规范 generation 分类,并在不读取或改变正文的前提下转换受支持的历史 header。历史 `open` 会共享每个 Session 唯一的一次 migration preparation,再返回当前逻辑值,并保持每个源路径、字节与 inode 不变。JSONL provider 直接从该内存结果返回读句柄而不发布;写 open 则在持有单写者 claim 与文件 lease 时复用 preparation、排他发布最终 current generation,随后才返回可写句柄。即使仍有较旧的可读 generation,最高的未来 generation 仍会导致拒绝。当前 v2 恢复会保留已安装扩展和带 `ignorable: true` 的未知事件;历史 v0/v1 迁移则会拒绝未知类型,即使它带有 ignorable 标记。后端为每个会话保留独立文件时,消息附上选定的原始日志路径。仓库外后端必须在自己的物理格式入口提供等价的仅当前句柄值与方向感知拒绝。[已发布格式迁移决策](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)负责迁移链与不可变发布规则。
 
 ## `CreateSessionOptions`:seed 与元数据
 
@@ -214,29 +227,37 @@ interface CreateSessionOptions {
 
 ## 准备与恢复所有权
 
-`SessionStore.prepare()` 接收普通创建选项,或通过 `RestoredSessionOptions` 转移所有权的全新的持久化对象图。恢复分支会就地验证并冻结转移来的 header 与事件,因此调用方不得保留可变别名。`SessionPreparation` 随后持有该精确的未发布 Session,直至发布或回滚;dispose 是同步且幂等的。agent-loop 的 resume 通过该会话的写句柄读取已存储的日志,并在准备之前追加所需的 `interruptedTurnClosers`,以此构建这些对象图。
+`SessionStore.prepare()` 接收普通创建选项,或通过 `RestoredSessionOptions` 接收可直接接管的 seed。它的 `eventState` 表明 event value 是独占对象,还是只有深度冻结后的共享对象;生产者负责建立该状态,slice 不会根据结果长度推断其他状态。恢复流程会校验并直接接管这些值,不再复制或冻结。`SessionPreparation` 随后持有该精确的未发布 Session,直至发布或回滚;dispose 是同步且幂等的。agent-loop 的 resume 通过该会话的写句柄读取这份结果,并在准备之前追加独占的 `interruptedTurnClosers`。
+
+```ts type-equiv
+/**
+ * Aliasing state of an adoptable Session seed. `shared-frozen` permits deeply
+ * frozen aliases plus independently owned unfrozen values in the same seed.
+ */
+type SessionSeedEventState = 'detached' | 'shared-frozen'
+```
 
 ```ts type-equiv
 /**
- * Fresh storage values transferred to {@link SessionStore.prepare} without a
- * second serialization copy. Callers retain no mutable aliases.
+ * Adoptable storage values transferred to {@link SessionStore.prepare}
+ * without another copy or freeze pass.
  */
 interface RestoredSessionOptions {
-  /** Fresh detached storage events to validate and freeze in place. */
+  /** Events that are independently owned or already deeply frozen. */
   readonly seed: SessionEvent[]
-  /** Fresh detached storage metadata to validate and freeze in place. */
+  /** Independently owned storage metadata to validate and freeze in place. */
   readonly meta: SessionHeader
   /** Exact number of fork-inherited leading events decoded from storage. */
   readonly inheritedEventCount: SessionLogOffset
-  /** Select the persistence ownership-transfer path. */
-  readonly seedSource: 'persistence'
+  /** Aliasing state carried from the operation that produced the seed. */
+  readonly eventState: SessionSeedEventState
 }
 ```
 
 ```ts type-equiv
 /** Inputs accepted while constructing an unpublished Session. */
 type PrepareSessionOptions =
-  | (CreateSessionOptions & { readonly seedSource?: undefined })
+  | (CreateSessionOptions & { readonly eventState?: undefined })
   | RestoredSessionOptions
 ```
 

+ 2 - 2
docs/subsystems/session.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/session.md
-session.md: 35dd9d7470ed8f1d067cd0d50b8b5c9d0c4da7ff
-session.zh.md: eb4b5e850d49472819d2d41611eed4c7dcbfbbf3
+session.md: 90051ca8668173daab4a003cf574f5ec64073223
+session.zh.md: 5de78f22697f71ec8c15824d36b4b93f31fc6a94

+ 12 - 9
docs/subsystems/session.md

@@ -440,13 +440,16 @@ declare class Session {
     inheritedEventCount?: SessionLogOffset,
   ): Session;
   /**
-   * Restore a detached session by taking ownership of fresh persistence values.
-   * The storage format, event envelopes, sequence continuity, surface transitions,
-   * and header fields are validated before the restored objects are frozen.
+   * Restore a detached session by adopting an independently owned or deeply frozen seed.
+   * Runtime-required event fields, event envelopes, sequence continuity, surface
+   * transitions, and header fields are validated without copying or freezing events.
+   * Embedded Assistant streams remain opaque until a stream consumer or storage
+   * verifier reads them.
    * @param id - restored session identity.
-   * @param seed - fresh detached events whose ownership is transferred.
-   * @param header - fresh detached metadata whose ownership is transferred.
+   * @param seed - independently owned or deeply frozen events.
+   * @param header - independently owned storage metadata.
    * @param inheritedEventCount - exact fork-inherited prefix length decoded from storage.
+   * @param eventState - aliasing state carried from the operation that produced the seed.
    * @returns a restored detached session.
    */
   static fromRestore(
@@ -454,6 +457,7 @@ declare class Session {
     seed: readonly SessionEvent[],
     header: SessionHeader,
     inheritedEventCount: SessionLogOffset,
+    eventState: SessionSeedEventState,
   ): Session;
   /**
    * Return the immutable event stored at one exact sequence number.
@@ -861,10 +865,9 @@ create(id?: SessionId, options?: CreateSessionOptions): Session
  *
  * @param id - the session id; omitted, the store mints `session-<n>`.
  * @param options - seed events and/or creation metadata for the header. With
- *   `seedSource: 'persistence'`, metadata and events must be fresh detached
- *   graphs whose ownership transfers to this call: they are validated and
- *   frozen in place through {@link Session.fromRestore}, so the caller must
- *   retain no mutable aliases.
+ *   `eventState`, every seed event is either independently owned or any
+ *   shared value is deeply frozen; {@link Session.fromRestore} validates and
+ *   adopts those values without copying or freezing them.
  * @returns the constructed session, NOT yet in the store.
  * @throws if a session with `id` already exists, metadata is not a plain
  *   lossless-JSON record with valid scalar fields, or `meta.cwd` is a

+ 12 - 9
docs/subsystems/session.zh.md

@@ -442,13 +442,16 @@ declare class Session {
     inheritedEventCount?: SessionLogOffset,
   ): Session;
   /**
-   * Restore a detached session by taking ownership of fresh persistence values.
-   * The storage format, event envelopes, sequence continuity, surface transitions,
-   * and header fields are validated before the restored objects are frozen.
+   * Restore a detached session by adopting an independently owned or deeply frozen seed.
+   * Runtime-required event fields, event envelopes, sequence continuity, surface
+   * transitions, and header fields are validated without copying or freezing events.
+   * Embedded Assistant streams remain opaque until a stream consumer or storage
+   * verifier reads them.
    * @param id - restored session identity.
-   * @param seed - fresh detached events whose ownership is transferred.
-   * @param header - fresh detached metadata whose ownership is transferred.
+   * @param seed - independently owned or deeply frozen events.
+   * @param header - independently owned storage metadata.
    * @param inheritedEventCount - exact fork-inherited prefix length decoded from storage.
+   * @param eventState - aliasing state carried from the operation that produced the seed.
    * @returns a restored detached session.
    */
   static fromRestore(
@@ -456,6 +459,7 @@ declare class Session {
     seed: readonly SessionEvent[],
     header: SessionHeader,
     inheritedEventCount: SessionLogOffset,
+    eventState: SessionSeedEventState,
   ): Session;
   /**
    * Return the immutable event stored at one exact sequence number.
@@ -865,10 +869,9 @@ create(id?: SessionId, options?: CreateSessionOptions): Session
  *
  * @param id - the session id; omitted, the store mints `session-<n>`.
  * @param options - seed events and/or creation metadata for the header. With
- *   `seedSource: 'persistence'`, metadata and events must be fresh detached
- *   graphs whose ownership transfers to this call: they are validated and
- *   frozen in place through {@link Session.fromRestore}, so the caller must
- *   retain no mutable aliases.
+ *   `eventState`, every seed event is either independently owned or any
+ *   shared value is deeply frozen; {@link Session.fromRestore} validates and
+ *   adopts those values without copying or freezing them.
  * @returns the constructed session, NOT yet in the store.
  * @throws if a session with `id` already exists, metadata is not a plain
  *   lossless-JSON record with valid scalar fields, or `meta.cwd` is a

+ 4 - 1
packages/api/session-controller/tests/test-remote.ts

@@ -141,7 +141,10 @@ function testReadHandle(
     access: 'read',
     read: (offset = 0, length?: number, options?: SessionHandleReadOptions) => {
       options?.signal?.throwIfAborted()
-      return Promise.resolve(events.slice(offset, length === undefined ? undefined : offset + length))
+      return Promise.resolve({
+        eventState: 'detached',
+        events: structuredClone(events.slice(offset, length === undefined ? undefined : offset + length)),
+      } as const)
     },
     append: () => Promise.reject(new SessionReadOnlyError(sessionId, 'append')),
     flush: () => Promise.reject(new SessionReadOnlyError(sessionId, 'flush')),

+ 3 - 2
packages/core/agent-loop/src/index.ts

@@ -873,15 +873,16 @@ export class AgentLoop extends Service implements AgentFactory {
           // back the physically valid log; an interrupted final turn receives
           // synthetic closers (missing tool errors, step/end, turn/end) that
           // are appended through the same handle as an ordinary batch.
-          const persisted = await handle.read(0, undefined, { signal: fused })
+          const coldRead = await handle.read(0, undefined, { signal: fused })
           fused.throwIfAborted()
+          const persisted = coldRead.events
           const closers = interruptedTurnClosers(persisted)
           if (closers.length > 0) await handle.append(closers)
           preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(id, {
             seed: [...persisted, ...closers],
             meta: structuredClone(handle.header),
             inheritedEventCount: handle.inheritedEventCount,
-            seedSource: 'persistence',
+            eventState: coldRead.eventState,
           }))
           stored = { handle, storedCount: persisted.length + closers.length }
           await this.appendUnstoredSuffix(stored, preparation.session)

+ 2 - 1
packages/core/agent-loop/tests/cancel.spec.ts

@@ -543,9 +543,10 @@ describe('Agent.cancel()', () => {
     expect(message?.type === 'assistant/message' ? message.data.interrupted : undefined).toBe(true)
     expect(() => Session.fromRestore(
       agent.session.id,
-      structuredClone(agent.session.snapshotEvents()),
+      structuredClone([...agent.session.snapshotEvents()]),
       structuredClone(agent.session.header),
       SessionLogOffset(0),
+      'detached',
     )).not.toThrow()
   })
 

+ 1 - 1
packages/core/agent-loop/tests/config-session-id.spec.ts

@@ -43,7 +43,7 @@ async function makeCoreContext(): Promise<Context> {
 async function readStoredEvents(ctx: Context, sessionId: SessionId): Promise<readonly SessionEvent[]> {
   const handle = await ctx.sessionPersistence.open(sessionId, 'read')
   try {
-    return await handle.read()
+    return (await handle.read()).events
   } finally {
     await handle.close()
   }

+ 1 - 1
packages/core/agent-loop/tests/resume.spec.ts

@@ -63,7 +63,7 @@ async function seedStoredSession(ctx: Context, sessionId: SessionId, events: rea
 async function readStoredEvents(ctx: Context, sessionId: SessionId): Promise<readonly SessionEvent[]> {
   const handle = await ctx.sessionPersistence.open(sessionId, 'read')
   try {
-    return await handle.read()
+    return (await handle.read()).events
   } finally {
     await handle.close()
   }

+ 1 - 1
packages/core/agent-loop/tests/shutdown-drain.spec.ts

@@ -61,7 +61,7 @@ describe.each(['backend-first', 'loop-first'] as const)('root shutdown drain (%s
     const verify = new Context()
     await verify.plugin(JsonlSessionPersistence, { root })
     const reader = await verify.sessionPersistence.open(sessionId, 'read')
-    const events = await reader.read()
+    const { events } = await reader.read()
     await reader.close()
     expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } })
     await verify.fiber.dispose()

+ 55 - 61
packages/core/session/src/index.ts

@@ -9,14 +9,13 @@
 import { Context, Service } from '@deepseek-ai/cordis'
 import { isAbsolute } from 'node:path'
 import { brandString } from '@deepseek-ai/dsh-brand'
-import { deepEqualJson, deepFreeze, snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
+import { assertNever, deepFreeze, snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
 import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
 import type { Scoped } from '@deepseek-ai/dsh-scope'
-import { BlockAssembler, expandAssistantStream } from '@deepseek-ai/dsh-llm'
 import type { Message } from '@deepseek-ai/dsh-llm'
 import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from './types.ts'
 import type { TypertLookup } from '@deepseek-ai/dsh-typert-protocol'
-import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SurfaceIntent, SurfaceEventType } from './types.ts'
+import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SessionSeedEventState, SurfaceIntent, SurfaceEventType } from './types.ts'
 import { deriveEventMessage, SurfaceManager } from './surface.ts'
 import type { SessionSurface } from './surface.ts'
 import { foldRequestHeader } from './request-header.ts'
@@ -192,22 +191,6 @@ export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
   return adoptSessionEvent(structuredClone(event))
 }
 
-/** Deep-freeze one acyclic JSON tree without consuming the JavaScript call stack. */
-function freezeRestoredObject<T extends object>(value: T): T {
-  const pending: object[] = [value]
-  while (pending.length > 0) {
-    // The non-empty check proves an object remains to visit.
-    // oxlint-disable-next-line typescript/no-non-null-assertion
-    const current = pending.pop()!
-    Object.freeze(current)
-    for (const key in current) {
-      const child = (current as Record<string, unknown>)[key]
-      if (child !== null && typeof child === 'object') pending.push(child)
-    }
-  }
-  return value
-}
-
 /** Validate the fixed event envelope after one-pass JSON materialization. */
 function assertSessionEventEnvelope(value: Record<string, unknown>, index: number): asserts value is SessionEvent {
   const event = value
@@ -276,41 +259,29 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
   }
   const type = event['type']
   if (type === 'assistant/attempt') {
-    assertCurrentAssistantStream(record, type, index)
+    assertAssistantSettlementShape(record, type, index)
     return
   }
   if (type !== 'user/message' && type !== 'assistant/message'
     && type !== 'tool/result') return
   assertMessageEventShape(event, `seed ${type} at index ${index}`)
-  if (type === 'assistant/message') assertCurrentAssistantStream(record, type, index)
+  if (type === 'assistant/message') {
+    assertAssistantSettlementShape(record, type, index)
+  }
 }
 
-/** Validate the current settlement stream and its duplicated message fields at a durable restore boundary. */
-function assertCurrentAssistantStream(
+/** Validate fields used directly by restored Session lifecycle logic without replaying the embedded stream. */
+function assertAssistantSettlementShape(
   data: Record<string, unknown> | undefined,
   type: 'assistant/attempt' | 'assistant/message',
   index: number,
 ): void {
-  const assembler = new BlockAssembler()
-  let timed: ReturnType<typeof expandAssistantStream>
-  try {
-    timed = expandAssistantStream(data?.['stream'] as never)
-    for (const member of timed) assembler.push(member.chunk)
-  } catch (error: unknown) {
-    throw new Error(`seed ${type} at index ${index} has an invalid embedded stream`, { cause: error })
-  }
-  if (type === 'assistant/attempt' || timed.length === 0) return
-  const message = data?.['message'] as Record<string, unknown>
-  const content = data?.['interrupted'] === true ? assembler.interruptedBlocks() : assembler.blocks()
-  if (!deepEqualJson(message['content'], content)) {
-    throw new Error(`seed assistant/message at index ${index} content disagrees with its embedded stream`)
-  }
-  if (!deepEqualJson(data?.['usage'], assembler.usage)) {
-    throw new Error(`seed assistant/message at index ${index} usage disagrees with its embedded stream`)
-  }
-  const source = message['source'] as Record<string, unknown>
-  if (!deepEqualJson(source['replayState'], assembler.replayState)) {
-    throw new Error(`seed assistant/message at index ${index} replay state disagrees with its embedded stream`)
+  const turn = data?.['turn']
+  const step = data?.['step']
+  if (typeof turn !== 'number' || !Number.isSafeInteger(turn) || turn < 0 || Object.is(turn, -0)
+    || typeof step !== 'number' || !Number.isSafeInteger(step) || step < 0 || Object.is(step, -0)
+    || !Array.isArray(data?.['stream'])) {
+    throw new Error(`seed ${type} at index ${index} has invalid settlement fields`)
   }
 }
 
@@ -520,13 +491,16 @@ export class Session {
   }
 
   /**
-   * Restore a detached session by taking ownership of fresh persistence values.
-   * The storage format, event envelopes, sequence continuity, surface transitions,
-   * and header fields are validated before the restored objects are frozen.
+   * Restore a detached session by adopting an independently owned or deeply frozen seed.
+   * Runtime-required event fields, event envelopes, sequence continuity, surface
+   * transitions, and header fields are validated without copying or freezing events.
+   * Embedded Assistant streams remain opaque until a stream consumer or storage
+   * verifier reads them.
    * @param id - restored session identity.
-   * @param seed - fresh detached events whose ownership is transferred.
-   * @param header - fresh detached metadata whose ownership is transferred.
+   * @param seed - independently owned or deeply frozen events.
+   * @param header - independently owned storage metadata.
    * @param inheritedEventCount - exact fork-inherited prefix length decoded from storage.
+   * @param eventState - aliasing state carried from the operation that produced the seed.
    * @returns a restored detached session.
    */
   static fromRestore(
@@ -534,20 +508,25 @@ export class Session {
     seed: readonly SessionEvent[],
     header: SessionHeader,
     inheritedEventCount: SessionLogOffset,
+    eventState: SessionSeedEventState,
   ): Session {
-    return new Session(id, seed, header, 'restore', inheritedEventCount)
+    return new Session(
+      id,
+      seed,
+      header,
+      eventState,
+      inheritedEventCount,
+    )
   }
 
   private constructor(
     id: SessionId,
     seed?: readonly SessionEvent[],
     header?: SessionHeader,
-    mode: 'snapshot' | 'restore' = 'snapshot',
+    mode: 'snapshot' | SessionSeedEventState = 'snapshot',
     suppliedInheritedEventCount?: SessionLogOffset,
   ) {
-    const restoredHeader = mode === 'restore'
-      ? validateRestoredSessionHeader(id, header)
-      : undefined
+    const restoredHeader = mode === 'snapshot' ? undefined : validateRestoredSessionHeader(id, header)
     if (seed !== undefined) {
       // Validate the seed to the SAME invariants `append` enforces, so a
       // replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
@@ -559,7 +538,7 @@ export class Session {
       for (const [index, source] of seed.entries()) {
         // The seed is a persistence/replay boundary: validate and detach the
         // complete event in one lossless-JSON pass.
-        const snapshot = mode === 'restore' ? source : snapshotJsonValue(source)
+        const snapshot = mode === 'snapshot' ? snapshotJsonValue(source) : source
         if (snapshot === undefined) {
           throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`)
         }
@@ -575,7 +554,7 @@ export class Session {
         } catch (error: unknown) {
           throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`)
         }
-        this.log.push(mode === 'restore' ? freezeRestoredObject(snapshot) : deepFreeze(snapshot))
+        this.log.push(mode === 'snapshot' ? deepFreeze(snapshot) : snapshot)
       }
     }
     this.firstLiveSeq = SessionLogOffset(this.log.length)
@@ -946,10 +925,9 @@ export class SessionStore extends Service {
    *
    * @param id - the session id; omitted, the store mints `session-<n>`.
    * @param options - seed events and/or creation metadata for the header. With
-   *   `seedSource: 'persistence'`, metadata and events must be fresh detached
-   *   graphs whose ownership transfers to this call: they are validated and
-   *   frozen in place through {@link Session.fromRestore}, so the caller must
-   *   retain no mutable aliases.
+   *   `eventState`, every seed event is either independently owned or any
+   *   shared value is deeply frozen; {@link Session.fromRestore} validates and
+   *   adopts those values without copying or freezing them.
    * @returns the constructed session, NOT yet in the store.
    * @throws if a session with `id` already exists, metadata is not a plain
    *   lossless-JSON record with valid scalar fields, or `meta.cwd` is a
@@ -964,8 +942,24 @@ export class SessionStore extends Service {
       sessionId = brandString<SessionId>(id)
     }
     if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
-    if (options?.seedSource === 'persistence') {
-      return Session.fromRestore(sessionId, options.seed, options.meta, options.inheritedEventCount)
+    if (options !== undefined) {
+      const { eventState } = options
+      switch (eventState) {
+        case 'detached':
+        case 'shared-frozen':
+          return Session.fromRestore(
+            sessionId,
+            options.seed,
+            options.meta,
+            options.inheritedEventCount,
+            eventState,
+          )
+        case undefined:
+          break
+        /* v8 ignore next -- closed-union exhaustiveness guard */
+        default:
+          assertNever(eventState, 'SessionStore.prepare event state')
+      }
     }
     const seed = options?.seed
     const meta = options?.meta

+ 13 - 7
packages/core/session/src/types.ts

@@ -157,23 +157,29 @@ export interface CreateSessionOptions {
 }
 
 /**
- * Fresh storage values transferred to {@link SessionStore.prepare} without a
- * second serialization copy. Callers retain no mutable aliases.
+ * Aliasing state of an adoptable Session seed. `shared-frozen` permits deeply
+ * frozen aliases plus independently owned unfrozen values in the same seed.
+ */
+export type SessionSeedEventState = 'detached' | 'shared-frozen'
+
+/**
+ * Adoptable storage values transferred to {@link SessionStore.prepare}
+ * without another copy or freeze pass.
  */
 export interface RestoredSessionOptions {
-  /** Fresh detached storage events to validate and freeze in place. */
+  /** Events that are independently owned or already deeply frozen. */
   readonly seed: SessionEvent[]
-  /** Fresh detached storage metadata to validate and freeze in place. */
+  /** Independently owned storage metadata to validate and freeze in place. */
   readonly meta: SessionHeader
   /** Exact number of fork-inherited leading events decoded from storage. */
   readonly inheritedEventCount: SessionLogOffset
-  /** Select the persistence ownership-transfer path. */
-  readonly seedSource: 'persistence'
+  /** Aliasing state carried from the operation that produced the seed. */
+  readonly eventState: SessionSeedEventState
 }
 
 /** Inputs accepted while constructing an unpublished Session. */
 export type PrepareSessionOptions =
-  | (CreateSessionOptions & { readonly seedSource?: undefined })
+  | (CreateSessionOptions & { readonly eventState?: undefined })
   | RestoredSessionOptions
 
 /** Why an active agent driver was cancelled. */

+ 7 - 5
packages/core/session/tests/sequence-types.spec.ts

@@ -48,11 +48,13 @@ describe('Session log positions', () => {
 
   it('rejects a negative-zero seq at the restored event boundary', () => {
     const id = SessionId('negative-zero-event')
-    expect(() => Session.fromRestore(id, [{
-      type: 'turn/start', seq: -0, time: 1, data: { turn: 1 },
-    }] as never, {
-      version: SESSION_FORMAT_VERSION, id, createdAt: 1, isSeeded: false,
-    }, SessionLogOffset(0))).toThrow(/invalid event envelope/)
+    expect(() => Session.fromRestore(
+      id,
+      [{ type: 'turn/start', seq: -0, time: 1, data: { turn: 1 } }] as never,
+      { version: SESSION_FORMAT_VERSION, id, createdAt: 1, isSeeded: false },
+      SessionLogOffset(0),
+      'detached',
+    )).toThrow(/invalid event envelope/)
   })
 
   it('keeps fork lineage outside the logical header integer fields', () => {

+ 42 - 96
packages/core/session/tests/session.spec.ts

@@ -192,7 +192,7 @@ describe('Session', () => {
       .toEqual([unrelatedPrimitiveData])
   })
 
-  it('rejects malformed current Assistant streams at the restore boundary', () => {
+  it('validates Assistant settlement fields without replaying embedded streams', () => {
     const id = SessionId('invalid-restored-assistant-stream')
     const header = {
       version: SESSION_FORMAT_VERSION,
@@ -201,18 +201,30 @@ describe('Session', () => {
       isSeeded: false,
       delegationDepth: 0,
     } as const
-    const invalidAttempt = {
-      type: 'assistant/attempt',
-      seq: 0,
-      time: 1,
-      data: {
-        turn: 1,
-        step: 1,
-        stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [1], texts: ['only'] }],
-      },
-    } as unknown as SessionEvent
-    expect(() => Session.fromRestore(id, [invalidAttempt], header, SessionLogOffset(0)))
-      .toThrow(/invalid embedded stream/)
+    for (const data of [
+      null,
+      { turn: '1', step: 1, stream: [] },
+      { turn: -1, step: 1, stream: [] },
+      { turn: -0, step: 1, stream: [] },
+      { turn: 1.5, step: 1, stream: [] },
+      { turn: 1, step: '1', stream: [] },
+      { turn: 1, step: -1, stream: [] },
+      { turn: 1, step: -0, stream: [] },
+      { turn: 1, step: 1.5, stream: [] },
+      { turn: 1, step: 1, stream: null },
+    ]) {
+      const invalidAttempt = {
+        type: 'assistant/attempt', seq: 0, time: 1, data,
+      } as unknown as SessionEvent
+      expect(() => Session.fromRestore(
+        id,
+        [invalidAttempt],
+        header,
+        SessionLogOffset(0),
+        'detached',
+      ))
+        .toThrow(/invalid settlement fields/)
+    }
 
     const mismatchedMessage = {
       type: 'assistant/message',
@@ -231,57 +243,15 @@ describe('Session', () => {
       },
       surfaceOp: 'append',
     } as unknown as SessionEvent
-    expect(() => Session.fromRestore(id, [mismatchedMessage], header, SessionLogOffset(0)))
-      .toThrow(/disagrees with its embedded stream/)
-
-    const mismatchedUsage = {
-      type: 'assistant/message',
-      seq: 0,
-      time: 1,
-      data: {
-        turn: 1,
-        step: 1,
-        message: {
-          id: 'usage-message',
-          role: 'assistant',
-          content: [],
-          source: { kind: 'model', provider: 'mock', model: 'mock' },
-        },
-        stream: [{
-          type: 'chunk', time: 1,
-          chunk: { type: 'usage', usage: { inputTokens: 3, outputTokens: 2 } },
-        }],
-        usage: { inputTokens: 4, outputTokens: 2 },
-      },
-      surfaceOp: 'append',
-    } as unknown as SessionEvent
-    expect(() => Session.fromRestore(id, [mismatchedUsage], header, SessionLogOffset(0)))
-      .toThrow(/usage disagrees with its embedded stream/)
-
-    const mismatchedReplayState = {
-      type: 'assistant/message',
-      seq: 0,
-      time: 1,
-      data: {
-        turn: 1,
-        step: 1,
-        message: {
-          id: 'replay-state-message',
-          role: 'assistant',
-          content: [],
-          source: {
-            kind: 'model', provider: 'mock', model: 'mock', replayState: { response: { id: 'stored' } },
-          },
-        },
-        stream: [{
-          type: 'chunk', time: 1,
-          chunk: { type: 'finish', reason: { kind: 'stop' }, replayState: { response: { id: 'streamed' } } },
-        }],
-      },
-      surfaceOp: 'append',
-    } as unknown as SessionEvent
-    expect(() => Session.fromRestore(id, [mismatchedReplayState], header, SessionLogOffset(0)))
-      .toThrow(/replay state disagrees with its embedded stream/)
+    const restored = Session.fromRestore(
+      id,
+      [mismatchedMessage],
+      header,
+      SessionLogOffset(0),
+      'detached',
+    )
+    expect(restored.eventAt(SessionSeq(0))).toBe(mismatchedMessage)
+    expect(Object.isFrozen(mismatchedMessage)).toBe(false)
   })
 
   it('rejects historical or malformed request-header lifecycle markers on seed/load', () => {
@@ -1044,37 +1014,6 @@ describe('Session', () => {
     expect(() => { (appendedEvent.data.content[0] as { text: string }).text = 'mutated' }).toThrow(TypeError)
   })
 
-  it('iteratively freezes deeply nested restored event data', () => {
-    const depth = 20_000
-    const data: Record<string, unknown> = {}
-    let tail = data
-    for (let index = 0; index < depth; index += 1) {
-      const child: Record<string, unknown> = {}
-      tail['child'] = child
-      tail = child
-    }
-    const event = {
-      type: 'test/deep-restore', seq: 0, time: 1, data,
-    } as unknown as SessionEvent
-
-    expect(() => Session.fromRestore(SessionId('deep-restore'), [event], {
-      version: SESSION_FORMAT_VERSION,
-      id: SessionId('deep-restore'),
-      createdAt: 1,
-      isSeeded: false,
-    }, SessionLogOffset(0))).not.toThrow()
-
-    let current: unknown = event
-    let frozenNodes = 0
-    for (let index = 0; index <= depth + 1; index += 1) {
-      if (!Object.isFrozen(current)) break
-      frozenNodes += 1
-      current = (current as Record<string, unknown>)['data']
-        ?? (current as Record<string, unknown>)['child']
-    }
-    expect(frozenNodes).toBe(depth + 2)
-  })
-
   it('returns cached frozen event-array snapshots that do not grow after append', () => {
     const session = Session.create(SessionId('events-snapshot'))
     session.append('turn/start', { turn: 1 })
@@ -1150,7 +1089,13 @@ describe('Session', () => {
 
     expect(() => Session.create(SessionId('header-invalid'), undefined, new ExoticHeader()))
       .toThrow(/not losslessly JSON-serializable/)
-    expect(() => Session.fromRestore(SessionId('header-invalid'), [], new ExoticHeader(), SessionLogOffset(0)))
+    expect(() => Session.fromRestore(
+      SessionId('header-invalid'),
+      [],
+      new ExoticHeader(),
+      SessionLogOffset(0),
+      'detached',
+    ))
       .toThrow(/not a plain JSON record/)
     for (const header of [null, 1, []]) {
       expect(() => Session.fromRestore(
@@ -1158,6 +1103,7 @@ describe('Session', () => {
         [],
         header as unknown as SessionHeader,
         SessionLogOffset(0),
+        'detached',
       )).toThrow(/not a plain JSON record/)
     }
     expect(() => Session.create(SessionId('header-invalid'), undefined, {

+ 2 - 1
packages/experimental/agent-team/src/persisted.ts

@@ -26,7 +26,8 @@ export async function readPersistedSession(
 ): Promise<PersistedSessionView> {
   const handle = await persistence.open(id, 'read', { signal })
   try {
-    return { header: handle.header, inheritedEventCount: handle.inheritedEventCount, events: await handle.read(0, undefined, { signal }) }
+    const { events } = await handle.read(0, undefined, { signal })
+    return { header: handle.header, inheritedEventCount: handle.inheritedEventCount, events }
   } finally {
     await handle.close()
   }

+ 1 - 1
packages/experimental/agent-team/tests/persistence.spec.ts

@@ -46,7 +46,7 @@ function durable(agent: Agent): {
 async function storedEvents(ctx: Context, id: SessionId): Promise<readonly SessionEvent[]> {
   const handle = await ctx.sessionPersistence.open(id, 'read')
   try {
-    return await handle.read()
+    return (await handle.read()).events
   } finally {
     await handle.close()
   }

+ 1 - 1
packages/experimental/agent-team/tests/team.spec.ts

@@ -50,7 +50,7 @@ function durable(agent: Agent): {
 async function storedEvents(ctx: Context, id: SessionId): Promise<readonly SessionEvent[]> {
   const handle = await ctx.sessionPersistence.open(id, 'read')
   try {
-    return await handle.read()
+    return (await handle.read()).events
   } finally {
     await handle.close()
   }

+ 3 - 1
packages/experimental/agent-team/tests/test-session-query.ts

@@ -43,7 +43,9 @@ export class TestSessionQuery extends SessionQueryEngine {
       return cut(
         'prepared',
         handle.header,
-        await handle.read(0, undefined, options.signal === undefined ? {} : { signal: options.signal }),
+        (await handle.read(
+          0, undefined, options.signal === undefined ? {} : { signal: options.signal },
+        )).events,
       )
     } finally {
       await handle.close()

+ 2 - 0
packages/experimental/webworker-runtime/tests/vfs-example-fixture.spec.ts

@@ -117,6 +117,7 @@ describe('WebWorker preview VFS example', () => {
       events,
       meta,
       inheritedEventCount,
+      'detached',
     )).not.toThrow()
 
     const messages = events.filter(event =>
@@ -158,6 +159,7 @@ describe('WebWorker preview VFS example', () => {
         events,
         meta,
         inheritedEventCount,
+        'detached',
       )).not.toThrow()
     }
   })

+ 13 - 5
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -1848,7 +1848,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       {
         signature: 'prepare(id?: SessionId, options?: PrepareSessionOptions): Session',
         description: 'Build a session WITHOUT entering it into the store — validate the id/cwd and construct the Session (with its immutable SessionHeader). Pairs with enter + announce: a caller that owns a composite `ctx.effect` (the agent factory) folds the session lifecycle into that ONE effect so a fiber unload tears the session + agent down as a single ORDERED chain rather than as racing sibling effects — which would remove the publication hooks before the driver\'s closing events commit, dropping them.',
-        parameters: [{ name: 'id', description: 'the session id; omitted, the store mints `session-<n>`.' }, { name: 'options', description: 'seed events and/or creation metadata for the header. With `seedSource: \'persistence\'`, metadata and events must be fresh detached graphs whose ownership transfers to this call: they are validated and frozen in place through {@link Session.fromRestore}, so the caller must retain no mutable aliases.' }],
+        parameters: [{ name: 'id', description: 'the session id; omitted, the store mints `session-<n>`.' }, { name: 'options', description: 'seed events and/or creation metadata for the header. With `eventState`, every seed event is either independently owned or any shared value is deeply frozen; {@link Session.fromRestore} validates and adopts those values without copying or freezing them.' }],
         returns: 'the constructed session, NOT yet in the store.',
         throws: ['if a session with `id` already exists, metadata is not a plain lossless-JSON record with valid scalar fields, or `meta.cwd` is a non-absolute path.'],
       },
@@ -4679,7 +4679,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'PrepareSessionOptions',
-    declaration: 'export type PrepareSessionOptions = (CreateSessionOptions & {\n    readonly seedSource?: undefined;\n}) | RestoredSessionOptions;',
+    declaration: 'export type PrepareSessionOptions = (CreateSessionOptions & {\n    readonly eventState?: undefined;\n}) | RestoredSessionOptions;',
   },
   {
     name: 'PresetOption',
@@ -4847,7 +4847,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'RestoredSessionOptions',
-    declaration: 'export interface RestoredSessionOptions {\n    readonly seed: SessionEvent[];\n    readonly meta: SessionHeader;\n    readonly inheritedEventCount: SessionLogOffset;\n    readonly seedSource: \'persistence\';\n}',
+    declaration: 'export interface RestoredSessionOptions {\n    readonly seed: SessionEvent[];\n    readonly meta: SessionHeader;\n    readonly inheritedEventCount: SessionLogOffset;\n    readonly eventState: SessionSeedEventState;\n}',
   },
   {
     name: 'ResumeAgentOptions',
@@ -4939,7 +4939,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'Session',
-    declaration: 'export class Session {\n    get surface(): SessionSurface;\n    readonly header: SessionHeader;\n    readonly inheritedEventCount: SessionLogOffset;\n    get id(): SessionId;\n    readonly firstLiveSeq: SessionLogOffset;\n    static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader, inheritedEventCount?: SessionLogOffset): Session;\n    static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader, inheritedEventCount: SessionLogOffset): Session;\n    eventAt(seq: SessionSeq): SessionEvent | undefined;\n    snapshotEvents(fromSeq: SessionLogOffset = SessionLogOffset(0), toSeqExclusive: SessionLogOffset = this.seq): readonly SessionEvent[];\n    ownEvents(): readonly SessionEvent[];\n    isOwnSeq(seq: SessionSeq): boolean;\n    get seq(): SessionLogOffset;\n    append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n        opts: SurfaceIntent<T>\n    ] : [\n    ]): SessionEvent<T>;\n    requestHeader(): EpochHeader | undefined;\n    requestContext(): RequestContext | undefined;\n    deriveMessages(): Message[];\n    deriveEventMessage(event: SessionEvent): Message | null;\n}',
+    declaration: 'export class Session {\n    get surface(): SessionSurface;\n    readonly header: SessionHeader;\n    readonly inheritedEventCount: SessionLogOffset;\n    get id(): SessionId;\n    readonly firstLiveSeq: SessionLogOffset;\n    static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader, inheritedEventCount?: SessionLogOffset): Session;\n    static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader, inheritedEventCount: SessionLogOffset, eventState: SessionSeedEventState): Session;\n    eventAt(seq: SessionSeq): SessionEvent | undefined;\n    snapshotEvents(fromSeq: SessionLogOffset = SessionLogOffset(0), toSeqExclusive: SessionLogOffset = this.seq): readonly SessionEvent[];\n    ownEvents(): readonly SessionEvent[];\n    isOwnSeq(seq: SessionSeq): boolean;\n    get seq(): SessionLogOffset;\n    append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n        opts: SurfaceIntent<T>\n    ] : [\n    ]): SessionEvent<T>;\n    requestHeader(): EpochHeader | undefined;\n    requestContext(): RequestContext | undefined;\n    deriveMessages(): Message[];\n    deriveEventMessage(event: SessionEvent): Message | null;\n}',
   },
   {
     name: 'SessionAccess',
@@ -5087,7 +5087,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SessionHandle',
-    declaration: 'export interface SessionHandle extends AsyncDisposable {\n    readonly id: SessionId;\n    readonly header: SessionHeader;\n    readonly inheritedEventCount: SessionLogOffset;\n    readonly access: SessionAccess;\n    read(offset?: number, length?: number, options?: SessionHandleReadOptions): Promise<readonly SessionEvent[]>;\n    append(events: readonly SessionEvent[], options?: SessionHandleAppendOptions): Promise<void>;\n    flush(options?: SessionHandleFlushOptions): Promise<void>;\n    close(): Promise<void>;\n}',
+    declaration: 'export interface SessionHandle extends AsyncDisposable {\n    readonly id: SessionId;\n    readonly header: SessionHeader;\n    readonly inheritedEventCount: SessionLogOffset;\n    readonly access: SessionAccess;\n    read(offset?: number, length?: number, options?: SessionHandleReadOptions): Promise<SessionHandleReadResult>;\n    append(events: readonly SessionEvent[], options?: SessionHandleAppendOptions): Promise<void>;\n    flush(options?: SessionHandleFlushOptions): Promise<void>;\n    close(): Promise<void>;\n}',
   },
   {
     name: 'SessionHandleAppendOptions',
@@ -5101,6 +5101,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'SessionHandleReadOptions',
     declaration: 'export interface SessionHandleReadOptions {\n    readonly signal?: AbortSignal;\n}',
   },
+  {
+    name: 'SessionHandleReadResult',
+    declaration: 'export interface SessionHandleReadResult {\n    readonly eventState: SessionSeedEventState;\n    readonly events: readonly SessionEvent[];\n}',
+  },
   {
     name: 'SessionHeader',
     declaration: 'export interface SessionHeader {\n    readonly version: typeof SESSION_FORMAT_VERSION;\n    readonly id: SessionId;\n    readonly createdAt: number;\n    readonly cwd?: string;\n    readonly parentSession?: SessionId;\n    readonly isSeeded: boolean;\n    readonly origin?: \'subagent\';\n    readonly delegationDepth?: number;\n    readonly agentPreset?: string;\n}',
@@ -5293,6 +5297,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'SessionSearchValue',
     declaration: 'export interface SessionSearchValue {\n    readonly items: readonly SessionSearchItem[];\n    readonly hasMore: boolean;\n}',
   },
+  {
+    name: 'SessionSeedEventState',
+    declaration: 'export type SessionSeedEventState = \'detached\' | \'shared-frozen\';',
+  },
   {
     name: 'SessionSelectModelRequest',
     declaration: 'export interface SessionSelectModelRequest extends ModelSelection {\n    readonly sessionId: SessionId;\n}',

+ 2 - 2
packages/feedback/message-feedback/src/index.ts

@@ -239,7 +239,7 @@ export class MessageFeedbackService extends TypertRemoteService {
         // Listener participation alone does not prove this Session has a persistence writer.
         const handle = await this.ctx.sessionPersistence.open(sessionId, 'read')
         try {
-          const stored = await handle.read(last?.seq ?? 0, 1)
+          const { events: stored } = await handle.read(last?.seq ?? 0, 1)
           if (!isDeepStrictEqual(
             [handle.header.id, handle.header.createdAt, handle.header.cwd],
             [live.header.id, live.header.createdAt, live.header.cwd],
@@ -254,7 +254,7 @@ export class MessageFeedbackService extends TypertRemoteService {
     }
     const handle = await this.ctx.sessionPersistence.open(sessionId, write ? 'write' : 'read')
     try {
-      const events = await handle.read()
+      const { events } = await handle.read()
       return await operation(events, async (event) => {
         const entry: FeedbackEvent | undefined = event === undefined ? undefined
           : { ...event, seq: SessionSeq(events.length), time: Date.now() }

+ 4 - 1
packages/feedback/message-feedback/tests/helpers.ts

@@ -162,7 +162,10 @@ class TestPersistence extends SessionPersistence {
         if (this.readFailure !== undefined) throw this.readFailure
         await this.onRead?.()
         const events = stored.events.filter(event => event.seq >= offset)
-        return length === undefined ? events : events.slice(0, length)
+        return {
+          eventState: 'detached',
+          events: structuredClone(length === undefined ? events : events.slice(0, length)),
+        }
       },
       append: async (events) => {
         if (closed) throw new SessionHandleClosedError(stored.meta.id, 'append')

+ 2 - 2
packages/feedback/message-feedback/tests/loader-composition.spec.ts

@@ -100,7 +100,7 @@ describe('message feedback through a real Loader composition', () => {
     })
     if (!put.ok) throw new Error(`expected put success, got ${put.error.code}`)
     const readHandle = await first.sessionPersistence.open(session.id, 'read')
-    const durableEvents = await readHandle.read()
+    const { events: durableEvents } = await readHandle.read()
     await readHandle.close()
     expect(durableEvents.some(event =>
       event.type === 'assistant/message'
@@ -126,7 +126,7 @@ describe('message feedback through a real Loader composition', () => {
     await second.messageFeedback.delete({ sessionId: session.id, messageId: edited.value.messageId, ifVersion: edited.value.version })
     const coldHandle = await second.sessionPersistence.open(session.id, 'read')
     try {
-      const coldEvents = await coldHandle.read()
+      const { events: coldEvents } = await coldHandle.read()
       expect(coldEvents.slice(0, durableEvents.length)).toEqual(durableEvents)
       expect(coldEvents.slice(durableEvents.length).map(event => event.type)).toEqual(['feedback/message-put', 'feedback/message-delete'])
     } finally {

+ 1 - 1
packages/llm/llm-retry/tests/persistence.spec.ts

@@ -57,7 +57,7 @@ describe('JSONL retry-event persistence', () => {
       const reader = await ctx.sessionPersistence.open(session.id, 'read')
       try {
         const loaded = await reader.read()
-        expect(loaded.find(item => item.type === 'llm/retry')).toEqual(event)
+        expect(loaded.events.find(item => item.type === 'llm/retry')).toEqual(event)
       } finally {
         await reader.close()
       }

+ 5 - 1
packages/schedule/schedule/tests/jsonl-restart.spec.ts

@@ -86,7 +86,11 @@ async function settleCurrentTasks(): Promise<void> {
 async function readStored(ctx: Context, id: SessionId) {
   const handle = await ctx.sessionPersistence.open(id, 'read')
   try {
-    return { header: handle.header, inheritedEventCount: handle.inheritedEventCount, events: await handle.read() }
+    return {
+      header: handle.header,
+      inheritedEventCount: handle.inheritedEventCount,
+      events: (await handle.read()).events,
+    }
   } finally {
     await handle.close()
   }

+ 1 - 1
packages/schedule/schedule/tests/plugin.spec.ts

@@ -64,7 +64,7 @@ class PersistenceProbe extends SessionPersistence {
       inheritedEventCount: SessionLogOffset(0),
       access,
       read: async (offset = 0, length = Number.MAX_SAFE_INTEGER) =>
-        entry.events.slice(offset, offset + length),
+        ({ eventState: 'detached', events: structuredClone(entry.events.slice(offset, offset + length)) }),
       append: async (events) => { entry.events.push(...events) },
       flush: async () => {},
       close: async () => {},

+ 1 - 1
packages/session-query/session-log-export/src/archive.ts

@@ -161,7 +161,7 @@ export async function readSessionLogText(
     throw error
   }
   try {
-    const events = await handle.read(0, undefined, options)
+    const { events } = await handle.read(0, undefined, options)
     return serializeSessionLog(handle.header, events)
   } finally {
     await handle.close()

+ 1 - 1
packages/session-query/session-log-export/tests/archive.host.spec.ts

@@ -83,7 +83,7 @@ function readHandle(stored: StoredLog): SessionHandle {
     header: stored.header,
     access: 'read',
     inheritedEventCount: 0,
-    read: async () => stored.events,
+    read: async () => ({ eventState: 'detached', events: structuredClone(stored.events) }),
     close: async () => {},
   } as unknown as SessionHandle
 }

+ 1 - 1
packages/session-query/session-log-export/tests/route.host.spec.ts

@@ -29,7 +29,7 @@ function readHandle(id: string): SessionHandle {
     id: header.id,
     header,
     access: 'read',
-    read: async () => [],
+    read: async () => ({ eventState: 'detached', events: [] }),
     close: async () => {},
   } as unknown as SessionHandle
 }

+ 4 - 3
packages/session-query/session-query-sqlite/tests/sqlite.spec.ts

@@ -17,6 +17,7 @@ import type {
   SessionAccess,
   SessionHandle,
   SessionHandleReadOptions,
+  SessionHandleReadResult,
   SessionPersistenceListOptions,
   SessionPersistenceSnapshot,
 } from '@deepseek-ai/dsh-session-persistence'
@@ -86,7 +87,7 @@ class TestHandle implements SessionHandle {
     readonly access: SessionAccess,
   ) {}
 
-  async read(_offset = 0, _length?: number, options?: SessionHandleReadOptions): Promise<readonly SessionEvent[]> {
+  async read(_offset = 0, _length?: number, options?: SessionHandleReadOptions): Promise<SessionHandleReadResult> {
     TestPersistence.reads.set(this.id, (TestPersistence.reads.get(this.id) ?? 0) + 1)
     TestPersistence.readSignals.push(options?.signal)
     if (TestPersistence.failure !== undefined) throw TestPersistence.failure
@@ -94,7 +95,7 @@ class TestHandle implements SessionHandle {
     if (entry === undefined) throw new SessionPersistenceNotFoundError(this.id)
     await TestPersistence.readEffect?.(entry, options?.signal)
     TestPersistence.readEffect = undefined
-    return structuredClone(entry.events)
+    return { eventState: 'detached', events: structuredClone(entry.events) }
   }
 
   append(events: readonly SessionEvent[]): Promise<void> {
@@ -1814,7 +1815,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
     await search.dispose()
     const reader = await ctx.sessionPersistence.open(meta.id, 'read')
     expect(reader.header).toMatchObject(meta)
-    await expect(reader.read()).resolves.toMatchObject([{ seq: SessionSeq(0) }])
+    await expect(reader.read()).resolves.toMatchObject({ events: [{ seq: SessionSeq(0) }] })
     await reader.close()
     await persistence.dispose()
   })

+ 14 - 5
packages/session-query/session-query/src/cold-read.ts

@@ -1,11 +1,14 @@
 /** One-shot cold session read through the handle-based persistence seam. */
 
 import { interruptedTurnClosers } from '@deepseek-ai/dsh-session'
-import type { SessionEvent, SessionHeader, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
+import type { SessionEvent, SessionHeader, SessionId, SessionLogOffset, SessionSeedEventState } from '@deepseek-ai/dsh-session'
 import type SessionPersistence from '@deepseek-ai/dsh-session-persistence'
+import type { SessionHandleReadResult } from '@deepseek-ai/dsh-session-persistence'
 
 /** A stored session log balanced for read-only viewing. */
 export interface ColdSessionLog {
+  /** Aliasing state of the persisted events; synthetic closers are locally owned. */
+  readonly eventState: SessionSeedEventState
   /** The stored header, fixed when the read handle opened. */
   readonly header: SessionHeader
   /** Exact fork-inherited event count paired with {@link header}. */
@@ -23,7 +26,7 @@ export interface ColdSessionLog {
  * @param persistence - the mounted persistence service.
  * @param sessionId - the stored session to read.
  * @param signal - optional cancellation for the open and read work.
- * @returns the stored header and the balanced event log.
+ * @returns an adoptable seed in a caller-owned outer array, ready for in-place Session restoration.
  */
 export async function readColdSessionLog(
   persistence: SessionPersistence,
@@ -32,9 +35,9 @@ export async function readColdSessionLog(
 ): Promise<ColdSessionLog> {
   const options = signal === undefined ? undefined : { signal }
   const handle = await persistence.open(sessionId, 'read', options)
-  let events: readonly SessionEvent[]
+  let read: SessionHandleReadResult
   try {
-    events = await handle.read(0, undefined, options)
+    read = await handle.read(0, undefined, options)
   } catch (error: unknown) {
     try {
       await handle.close()
@@ -44,5 +47,11 @@ export async function readColdSessionLog(
     throw error
   }
   await handle.close()
-  return { header: handle.header, inheritedEventCount: handle.inheritedEventCount, events: [...events, ...interruptedTurnClosers(events)] }
+  const { events } = read
+  return {
+    eventState: read.eventState,
+    header: handle.header,
+    inheritedEventCount: handle.inheritedEventCount,
+    events: [...events, ...interruptedTurnClosers(events)],
+  }
 }

+ 4 - 4
packages/session-query/session-query/src/observation.ts

@@ -111,16 +111,16 @@ export class SessionObservationReader {
         throwIfObservationAborted(signal)
         const attached = this.ctx.sessions.get(sessionId)
         if (attached !== undefined) return this.live(attached, projectionMode)
-        // Ownership transfer into `prepare` freezes the seed in place, so the
-        // entry keeps its own detached copies of the just-read events.
-        const seed = loaded.events.map(event => structuredClone(event))
+        // The handle marks persisted events as adoptable; synthetic closers
+        // are owned by this read, so the combined seed needs no copy.
+        const seed = loaded.events
         let session: Session
         try {
           session = this.ctx.sessions.prepare(sessionId, {
             seed,
             meta: structuredClone(loaded.header),
             inheritedEventCount: loaded.inheritedEventCount,
-            seedSource: 'persistence',
+            eventState: loaded.eventState,
           })
         } catch (error: unknown) {
           // The store rejects an id with a live owner: that owner is the

+ 15 - 5
packages/session-query/session-query/tests/observation.spec.ts

@@ -12,6 +12,7 @@ import type {
   SessionAccess,
   SessionHandle,
   SessionHandleReadOptions,
+  SessionHandleReadResult,
   SessionPersistenceSnapshot,
   SessionPersistenceStatOptions,
 } from '@deepseek-ai/dsh-session-persistence'
@@ -60,6 +61,8 @@ interface StubHooks {
   onStat?: () => void
   /** Runs inside `read` before it resolves. */
   onRead?: () => void
+  /** Observes the detached values returned by `read`. */
+  onReadResult?: (events: SessionEvent[]) => void
   /** Replaces the read result for every open handle. */
   readFailure?: unknown
   /** Replaces the stat result. */
@@ -104,7 +107,7 @@ function stubPersistence(
         _offset?: number,
         _length?: number,
         options?: SessionHandleReadOptions,
-      ): Promise<readonly SessionEvent[]> => {
+      ): Promise<SessionHandleReadResult> => {
         counters.read += 1
         void options
         hooks.onRead?.()
@@ -112,7 +115,9 @@ function stubPersistence(
           // oxlint-disable-next-line typescript/prefer-promise-reject-errors
           return Promise.reject(hooks.readFailure)
         }
-        return Promise.resolve(structuredClone(entry.events))
+        const events = structuredClone(entry.events)
+        hooks.onReadResult?.(events)
+        return Promise.resolve({ eventState: 'detached', events })
       },
       append: () => Promise.reject(new SessionReadOnlyError(id, 'append')),
       flush: () => Promise.reject(new SessionReadOnlyError(id, 'flush')),
@@ -176,7 +181,10 @@ describe('SessionObservationReader cold path', () => {
     const meta = header('interrupted-cold')
     const store = new Map([[meta.id, { header: meta, events: interruptedLog('crashed'), revision: 'r1' }]])
     const counters = { stat: 0, open: 0, read: 0 }
-    ctx.provide('sessionPersistence', stubPersistence(store, counters))
+    let restoredSource: SessionEvent[] | undefined
+    ctx.provide('sessionPersistence', stubPersistence(store, counters, {
+      onReadResult: (events) => { restoredSource = events },
+    }))
     const reader = new SessionObservationReader(ctx)
 
     using observed = await reader.read(meta.id)
@@ -185,6 +193,8 @@ describe('SessionObservationReader cold path', () => {
     expect(observed.header).toMatchObject({ id: meta.id, cwd: '/workspace' })
     expect(observed.revision).toBe(SessionPersistenceRevision('r1'))
     expect(observed.events.map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end'])
+    expect(observed.events[0]).toBe(restoredSource?.[0])
+    expect(Object.isFrozen(restoredSource?.[0]?.data)).toBe(false)
     expect(observed.cursor).toBe(2)
     // No projection registry is mounted, so the observation carries none.
     expect(observed.projections).toBeUndefined()
@@ -418,9 +428,9 @@ describe('SessionObservationReader cold path', () => {
     class SwapHandle implements SessionHandle {
       readonly inheritedEventCount = SessionLogOffset(0)
       constructor(readonly id: SessionIdType, readonly header: SessionHeader, readonly access: SessionAccess) {}
-      read(): Promise<readonly SessionEvent[]> {
+      read(): Promise<SessionHandleReadResult> {
         SwapPersistence.readCalls += 1
-        return Promise.resolve([messageEvent(0, 'swap')])
+        return Promise.resolve({ eventState: 'detached', events: [messageEvent(0, 'swap')] })
       }
 
       append(): Promise<void> {

+ 6 - 3
packages/session-query/session-query/tests/session-query.spec.ts

@@ -14,6 +14,7 @@ import type {
   SessionAccess,
   SessionHandle,
   SessionHandleReadOptions,
+  SessionHandleReadResult,
   SessionPersistenceSnapshot,
 } from '@deepseek-ai/dsh-session-persistence'
 import SessionQueryEngine, {
@@ -51,7 +52,7 @@ class TestHandle implements SessionHandle {
     readonly access: SessionAccess,
   ) {}
 
-  read(offset = 0, length?: number, options?: SessionHandleReadOptions): Promise<readonly SessionEvent[]> {
+  read(offset = 0, length?: number, options?: SessionHandleReadOptions): Promise<SessionHandleReadResult> {
     TestPersistence.readCalls.push(this.id)
     TestPersistence.readSignals.push(options?.signal)
     const slice = (events: SessionEvent[]): SessionEvent[] => {
@@ -59,7 +60,9 @@ class TestHandle implements SessionHandle {
       return length === undefined ? from : from.slice(0, length)
     }
     if (TestPersistence.readOverride !== undefined) {
-      return TestPersistence.readOverride(this.id, options?.signal).then(loaded => slice(loaded.events))
+      return TestPersistence.readOverride(this.id, options?.signal).then(loaded => ({
+        eventState: 'detached', events: structuredClone(slice(loaded.events)),
+      } as const))
     }
     if (TestPersistence.readFailure !== undefined) return rejectUnknown(TestPersistence.readFailure)
     const entry = TestPersistence.entries.get(this.id)
@@ -67,7 +70,7 @@ class TestHandle implements SessionHandle {
     const result = structuredClone(entry.events)
     TestPersistence.readEffect?.()
     TestPersistence.readEffect = undefined
-    return Promise.resolve(slice(result))
+    return Promise.resolve({ eventState: 'detached', events: slice(result) })
   }
 
   append(events: readonly SessionEvent[]): Promise<void> {

+ 3 - 2
packages/session-query/session-query/tests/tracing.spec.ts

@@ -12,6 +12,7 @@ import SessionPersistence, {
 import type {
   SessionAccess,
   SessionHandle,
+  SessionHandleReadResult,
   SessionPersistenceSnapshot,
 } from '@deepseek-ai/dsh-session-persistence'
 import { type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query'
@@ -49,12 +50,12 @@ class TraceHandle implements SessionHandle {
     readonly access: SessionAccess,
   ) {}
 
-  read(): Promise<readonly SessionEvent[]> {
+  read(): Promise<SessionHandleReadResult> {
     TracePersistence.readCalls += 1
     if (TracePersistence.readFailure !== undefined) return Promise.reject(TracePersistence.readFailure)
     const entry = TracePersistence.entries.get(this.id)
     if (entry === undefined) return Promise.reject(new SessionPersistenceNotFoundError(this.id))
-    return Promise.resolve(structuredClone(entry.events))
+    return Promise.resolve({ eventState: 'detached', events: structuredClone(entry.events) })
   }
 
   append(): Promise<void> {

+ 1 - 1
packages/session/session-checkpoint-policy/tests/crash-recovery.e2e.ts

@@ -74,7 +74,7 @@ async function load(root: string): Promise<SessionEvent[]> {
   try {
     const handle = await ctx.sessionPersistence.open(sessionId, 'read')
     try {
-      const events = await handle.read()
+      const { events } = await handle.read()
       return [...events, ...interruptedTurnClosers(events)]
     } finally {
       await handle.close()

+ 2 - 0
packages/session/session-format-catalog/src/current.ts

@@ -25,6 +25,7 @@ export function validateInstalledCurrentSessionHeader(header: SessionFormatHeade
     [],
     header as unknown as SessionHeader,
     SessionLogOffset(0),
+    'detached',
   )
 }
 
@@ -44,5 +45,6 @@ export function validateInstalledCurrentSessionArtifact(artifact: SessionFormatA
     artifact.events as SessionEvent[],
     artifact.header as unknown as SessionHeader,
     SessionLogOffset(artifact.inheritedEventCount),
+    'detached',
   )
 }

+ 2 - 2
packages/session/session-format-v1-to-v2/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/session/session-format-v1-to-v2/README.md
-README.md: 74a780b41735bb67676c79d44bef89dc0eb08d6e
-README.zh.md: 6007833173f4a98cb40fea68a2bd6f423b3e802f
+README.md: 9052dbe12a1e63fff332fbd51c8fc258f1a26ed0
+README.zh.md: 42f429f7fab2f012f81d2d3954327ab1431a3d70

+ 1 - 1
packages/session/session-format-v1-to-v2/README.md

@@ -49,7 +49,7 @@ The edge also closes the bounded legacy restart pattern in which a non-empty `ne
 
 The migration refuses a reference to a consumed chunk instead of redirecting it to a different semantic event. It remaps declared event provenance, surface replacements, command source events, compaction ranges and lists, and title message lists. The already model-visible `session/title-llm-request.messages` text remains byte-identical after source validation, so target validation does not reinterpret the old sequence numbers embedded in that prompt. A seeded source also refuses an inherited cut that splits an Assistant attempt; the target marks the exact cut with `session/end-seed { inherited: true }`.
 
-The v2 physical header requires `isSeeded` and does not store a numeric cut. The codec derives the cut from the last inherited end-seed marker, writes one event per row, range-encodes only `sourceEventSeqs`, and remains neutral to ordinary event vocabulary and payload growth. Released-current restoration admits event types known to the installed Session package plus unknown events carrying `ignorable: true`, and validates event members and relationships. Full current restoration additionally delegates payload and embedded-stream semantics to the installed Session package. The frozen exact writer-image validator lives under `src/testing` for edge fixtures.
+The v2 physical header requires `isSeeded` and does not store a numeric cut. The codec derives the cut from the last inherited end-seed marker, writes one event per row, range-encodes only `sourceEventSeqs`, and remains neutral to ordinary event vocabulary and payload growth. Released-current restoration admits event types known to the installed Session package plus unknown events carrying `ignorable: true`, and validates event members and relationships. Ordinary Session restoration checks runtime-required settlement fields without replaying embedded streams; persistence publication and the frozen writer-image fixture validator retain full stream verification.
 
 -----
 

+ 1 - 1
packages/session/session-format-v1-to-v2/README.zh.md

@@ -49,7 +49,7 @@ const eventRecord = releasedV2SessionFormatCodec.encodeEvent(currentEvent)
 
 如果引用指向被消费的 chunk,迁移会失败,而不会把它重定向到语义不同的事件。它会重映射已声明的事件 provenance、surface replacement、command source event、compaction range 与 list,以及 title message list。已经对模型可见的 `session/title-llm-request.messages` 文本会在源校验后保持逐字节不变,因此目标校验不会重新解释该 prompt 中嵌入的旧序号。带 seed 的源若让继承切点切开一个 Assistant attempt,也会迁移失败;目标会用 `session/end-seed { inherited: true }` 标出精确切点。
 
-v2 物理 header 要求 `isSeeded`,且不存储数值切点。编解码器从最后一个 inherited end-seed marker 推导切点,每行写入一个事件,只对 `sourceEventSeqs` 做范围编码,并对普通事件词汇与 payload 扩展保持中立。Released-current restoration 准入 installed Session package 已知的事件 type,以及携带 `ignorable: true` 的未知事件,并校验事件 member 与关系。完整 current restoration 还会把 payload 与嵌入 stream 语义交给 installed Session package。冻结的精确 writer-image 校验器位于 `src/testing`,供 edge fixture 使用。
+v2 物理 header 要求 `isSeeded`,且不存储数值切点。编解码器从最后一个 inherited end-seed marker 推导切点,每行写入一个事件,只对 `sourceEventSeqs` 做范围编码,并对普通事件词汇与 payload 扩展保持中立。Released-current restoration 准入 installed Session package 已知的事件 type,以及携带 `ignorable: true` 的未知事件,并校验事件 member 与关系。普通 Session restore 只检查 runtime 直接依赖的 settlement 字段,不重放嵌入 stream;persistence publication 与冻结的 writer-image fixture validator 保留完整 stream verification。
 
 -----
 

+ 1 - 1
packages/session/session-log-deepseek/tests/feedback-composition.spec.ts

@@ -132,7 +132,7 @@ it('uploads freeform feedback and message put/edit/delete through the unchanged
       ] })
     }
     await ctx.sessions.flush(session)
-    expect(await handle.read()).toEqual(session.snapshotEvents())
+    expect((await handle.read()).events).toEqual(session.snapshotEvents())
   } finally {
     await handle.close()
   }

+ 2 - 2
packages/session/session-persistence-jsonl/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/session/session-persistence-jsonl/README.md
-README.md: c4ed0769621a51223af616746ef877824abf48c9
-README.zh.md: 0b7ef652ef1a43b811dd0e77000a84217ffb2d40
+README.md: cce62bf98f1e906d8800b71e215e53b96bee8044
+README.zh.md: 6bc5f574ecd7266375039858cc9974e47618cc74

+ 2 - 2
packages/session/session-persistence-jsonl/README.md

@@ -75,7 +75,7 @@ A session is materialized lazily: `create(header)` writes nothing and returns th
 
 ### Reading the logs
 
-`open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. Before either kind of handle returns for historical input, the backend decodes and migrates the source once, encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, publishes the current successor without overwrite, and verifies and reopens the committed generation. The source remains byte-identical. The handle's `read(offset?, length?)` serves validated contiguous slices under the durability rules above. A write open primes the handle with the validated stored prefix, and a bounded revision-keyed memo lets an immediate observe-to-resume handoff reuse that parse. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry `sizeBytes` and a best-effort stat-derived revision for the selected file. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend.
+`open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry `sizeBytes` and a best-effort stat-derived revision for the selected file. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend.
 
 -----
 
@@ -89,7 +89,7 @@ This section explains the physical encoding and write path; the observable contr
 
 ### Design concept
 
-The backend owns its complete storage runtime (`src/storage.ts`): `JsonlSessionHandle` carries the per-handle mutation chain, the routed live-event buffer with its fixed batching window and single-flight drain, monotonic reads, and idempotent close; a tracker holds the in-process single-writer claims, the open-handle set teardown sweeps, and the created-but-unmaterialized pending sessions the backend's own session listeners route into. Historical body reads run the same serial ensure-current operation before constructing a handle. The package deliberately exposes only its default plugin export plus configuration types — the concrete class is not a named export, so consumers couple to `ctx.sessionPersistence`, and the shared seam suites (`runPersistenceContract`/`runLiveWritePathContract`) pin its observable behavior. Its change token is a best-effort file revision: device, inode, size, and nanosecond timestamps identify one log for `stat`/`list`, for the stable-read loop that retries a read torn by a concurrent append, and for the pre-publication source check.
+The backend owns its complete storage runtime (`src/storage.ts`): `JsonlSessionHandle` carries the per-handle mutation chain, the routed live-event buffer with its fixed batching window and single-flight drain, monotonic reads, and idempotent close; a tracker holds the in-process single-writer claims, the open-handle set teardown sweeps, and the created-but-unmaterialized pending sessions the backend's own session listeners route into. Historical body reads share one per-session Decode/Migrate preparation, and a bounded revision-keyed memo lets an immediate observe-to-resume handoff reuse that parse; the backend deep-freezes each event graph once before memoization, so later handle reads reuse it without copying or freezing. Only a write open publishes the prepared successor. The package deliberately exposes only its default plugin export plus configuration types — the concrete class is not a named export, so consumers couple to `ctx.sessionPersistence`, and the shared seam suites (`runPersistenceContract`/`runLiveWritePathContract`) pin its observable behavior. Its change token is a best-effort file revision: device, inode, size, and nanosecond timestamps identify one log for `stat`/`list`, for the stable-read loop that retries a read torn by a concurrent append, and for the pre-publication source check.
 
 ### Physical encoding
 

+ 2 - 2
packages/session/session-persistence-jsonl/README.zh.md

@@ -75,7 +75,7 @@ kind: "package-reference"
 
 ### 读取日志
 
-`open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,两种句柄都会在返回前等待后端单遍解码并迁移源、按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订、以不覆盖方式发布当前后继,并校验和重新打开已提交 generation。源保持逐字节不变。句柄的 `read(offset?, length?)` 按上述持久性规则提供经过验证的连续切片。写 open 会用已验证的存储前缀预热句柄,一个按 revision 为键的有界 memo 让紧接的观察到恢复交接复用该解析。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的 stat 派生修订号。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。
+`open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。Backend 在 memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的 stat 派生修订号。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。
 
 -----
 
@@ -89,7 +89,7 @@ kind: "package-reference"
 
 ### 设计理念
 
-该后端拥有自己完整的存储运行时(`src/storage.ts`):`JsonlSessionHandle` 承载逐句柄修改链、带固定批处理窗口与 single-flight 排空的已路由实时事件缓冲、单调读取与幂等 close;一个 tracker 持有进程内单写者认领、teardown 清扫所遍历的打开句柄集合,以及后端自己的会话监听器所路由进的已创建但未实体化待定会话。历史正文读取会在构造句柄前执行同一个串行 ensure-current 操作。本包有意只暴露默认插件导出与配置类型——具体类不是具名导出,因此消费方只耦合 `ctx.sessionPersistence`,其可观察行为由共享 seam 测试套件(`runPersistenceContract`/`runLiveWritePathContract`)钉住。其变更令牌是尽力而为的文件修订值:device、inode、size 与纳秒时间戳标识一份日志,供 `stat`/`list`、在并发 append 撕裂读取时重试的稳定读取循环,以及发布前源检查使用。
+该后端拥有自己完整的存储运行时(`src/storage.ts`):`JsonlSessionHandle` 承载逐句柄修改链、带固定批处理窗口与 single-flight 排空的已路由实时事件缓冲、单调读取与幂等 close;一个 tracker 持有进程内单写者认领、teardown 清扫所遍历的打开句柄集合,以及后端自己的会话监听器所路由进的已创建但未实体化待定会话。历史正文读取共享每个 Session 唯一的一次 Decode/Migrate preparation,按 revision 为键的有界 memo 让紧接的观察到恢复交接复用该解析;backend 在 memo 化前只对每个 event graph 深度冻结一次,因此后续 handle read 无需复制或再次冻结。只有写 open 才发布准备好的后继。本包有意只暴露默认插件导出与配置类型——具体类不是具名导出,因此消费方只耦合 `ctx.sessionPersistence`,其可观察行为由共享 seam 测试套件(`runPersistenceContract`/`runLiveWritePathContract`)钉住。其变更令牌是尽力而为的文件修订值:device、inode、size 与纳秒时间戳标识一份日志,供 `stat`/`list`、在并发 append 撕裂读取时重试的稳定读取循环,以及发布前源检查使用。
 
 ### 物理编码
 

+ 168 - 239
packages/session/session-persistence-jsonl/src/generation.ts

@@ -22,8 +22,11 @@ import { basename, dirname, join } from 'node:path'
 import { performance } from 'node:perf_hooks'
 import { pipeline, Readable } from 'node:stream'
 import { scheduler } from 'node:timers/promises'
+import { isDeepStrictEqual } from 'node:util'
 import { constants, createZstdCompress } from 'node:zlib'
 import { Session } from '@deepseek-ai/dsh-session'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import { BlockAssembler, expandAssistantStream } from '@deepseek-ai/dsh-llm'
 import type {
   SessionFormatArtifact,
   SessionFormatJsonValue,
@@ -62,8 +65,8 @@ export interface JsonlGenerationFormatAdapter {
   isUnsupportedMigrationError?(error: unknown): error is Error
 }
 
-/** Inputs for ensuring one already-resolved generation has a current successor. */
-export interface EnsureJsonlGenerationOptions {
+/** Inputs for preparing one historical generation and publishing its current successor later. */
+export interface PrepareJsonlMigrationOptions {
   /** Immutable generation selected by the backend resolver. */
   readonly sourcePath: string
   /** Version selected from the source filename and independently checked against its header. */
@@ -101,36 +104,24 @@ export interface JsonlExpectedPrefix {
   readonly digest: string
 }
 
-/** Result of current classification or exclusive publication. */
-export type EnsureJsonlGenerationResult =
-  | {
-    readonly status: 'current'
-    readonly version: number
-    readonly path: string
-    readonly snapshot: JsonlPhysicalSnapshot
-  }
-  | {
-    readonly status: 'migrated'
-    readonly fromVersion: number
-    readonly toVersion: number
-    readonly path: string
-    readonly sourcePath: string
-    readonly snapshot: JsonlPhysicalSnapshot
-  }
+/** A historical source changed after its single decode and migration pass. */
+export class JsonlGenerationSourceChangedError extends Error {
+  override readonly name = 'JsonlGenerationSourceChangedError'
 
-/** A future physical header was readable, but this writer cannot interpret it. */
-export class JsonlGenerationNewerVersionError extends Error {
-  override readonly name = 'JsonlGenerationNewerVersionError'
-
-  constructor(
-    readonly storedVersion: number,
-    readonly currentVersion: number,
-    readonly storedId: string,
-  ) {
-    super(`session log format v${storedVersion} is newer than current v${currentVersion}`)
+  /** @param path - historical generation whose revision changed. */
+  constructor(readonly path: string) {
+    super(`historical session generation changed during migration: "${path}"`)
   }
 }
 
+/** Current logical state prepared independently from durable publication. */
+export interface PreparedJsonlMigration {
+  readonly sourceIdentity: JsonlPhysicalIdentity
+  readonly artifact: SessionFormatArtifact
+  /** Encode, verify, and exclusively publish once; every call shares the same success or failure. */
+  publish(): Promise<JsonlPhysicalIdentity>
+}
+
 /** A historical artifact is intact, but the format edge refuses its contents. */
 export class JsonlGenerationUnsupportedMigrationError extends Error {
   override readonly name = 'JsonlGenerationUnsupportedMigrationError'
@@ -172,23 +163,12 @@ export interface JsonlPhysicalIdentity {
   readonly ctimeNs: bigint
 }
 
-/** One revision-stable physical artifact returned to the immediate backend decoder. */
-export interface JsonlPhysicalSnapshot extends StablePhysicalFile {
-  readonly headerValue: Record<string, unknown>
-  readonly headerRecord: Buffer
-}
-
 /** Exact bytes of one stable file revision together with the stat identity that proved it stable. */
 export interface StablePhysicalFile {
   readonly bytes: Buffer
   readonly identity: JsonlPhysicalIdentity
 }
 
-interface JsonlPhysicalHeader {
-  readonly value: Record<string, unknown>
-  readonly record: Buffer
-}
-
 interface GenerationFileSystem {
   open(path: string, flags: string, mode?: number): Promise<FileHandle>
   readFile(path: string, signal?: AbortSignal): Promise<Buffer>
@@ -219,7 +199,7 @@ export type JsonlGenerationRuntimeOverrides = Partial<Omit<JsonlGenerationIntern
 /** Bound generation operations used by production defaults and deterministic tests. */
 export interface JsonlGenerationRuntime {
   readStable(path: string, signal?: AbortSignal): Promise<StablePhysicalFile>
-  ensure(options: EnsureJsonlGenerationOptions): Promise<EnsureJsonlGenerationResult>
+  prepare(options: PrepareJsonlMigrationOptions): Promise<PreparedJsonlMigration>
   verify(
     path: string,
     compression: JsonlCompression,
@@ -260,10 +240,6 @@ function identity(value: JsonlPhysicalIdentity): string {
   return [value.dev, value.ino, value.size, value.mtimeNs, value.ctimeNs].join(':')
 }
 
-function fingerprint(value: JsonlPhysicalIdentity, bytes: Buffer): string {
-  return `${identity(value)}:${createHash('sha256').update(bytes).digest('hex')}`
-}
-
 /**
  * Read one stable revision of a JSONL file with a single retry. If an append
  * overlaps both reads, return the second read's committed pre-read prefix
@@ -313,10 +289,6 @@ function storedVersion(header: unknown): number {
   return version as number
 }
 
-function storedId(header: unknown): string {
-  return String((header as { id?: unknown }).id)
-}
-
 function parseJson(text: string, subject: string): unknown {
   try {
     return JSON.parse(text)
@@ -398,10 +370,15 @@ interface StartedMigrationStream {
 
 async function startMigrationStream(
   headerRecord: Buffer,
+  sourceVersion: number,
   format: JsonlGenerationFormatAdapter,
-  validateHistoricalHeader?: EnsureJsonlGenerationOptions['validateHistoricalHeader'],
+  validateHistoricalHeader?: PrepareJsonlMigrationOptions['validateHistoricalHeader'],
 ): Promise<StartedMigrationStream> {
   const value = parseJson(headerRecord.subarray(0, -1).toString('utf8'), 'header line')
+  const version = storedVersion(value)
+  if (version !== sourceVersion) {
+    throw new Error(`resolved JSONL source filename identifies v${sourceVersion}, but its header identifies v${version}`)
+  }
   const header = value as Record<string, unknown>
   const validation = validateHistoricalHeader?.(header)
   if (validation !== undefined) await validation
@@ -430,17 +407,18 @@ async function consumeMigrationBytes(
 async function decodeStreamingMigration(
   bytes: Buffer,
   compression: JsonlCompression,
+  sourceVersion: number,
   format: JsonlGenerationFormatAdapter,
-  validateHistoricalHeader: EnsureJsonlGenerationOptions['validateHistoricalHeader'],
+  validateHistoricalHeader: PrepareJsonlMigrationOptions['validateHistoricalHeader'],
   signal?: AbortSignal,
 ): Promise<SessionFormatArtifact> {
   signal?.throwIfAborted()
   if (compression === 'none') {
     const headerEnd = bytes.indexOf(0x0A)
-    /* v8 ignore next -- ensureCurrent's physical-header preflight already requires this newline. */
     if (headerEnd === -1) throw new Error('empty or header-less session log')
     const stream = await startMigrationStream(
       bytes.subarray(0, headerEnd + 1),
+      sourceVersion,
       format,
       validateHistoricalHeader,
     )
@@ -457,7 +435,6 @@ async function decodeStreamingMigration(
   }
 
   const { frames, tornStart } = scanZstdFrames(bytes)
-  /* v8 ignore next -- ensureCurrent's physical-header preflight already requires a complete header frame. */
   if (frames.length === 0) throw new Error('empty or header-less Zstandard session log')
   const decoder = createZstdFrameDecoder()
   try {
@@ -468,6 +445,7 @@ async function decodeStreamingMigration(
     assertIndependentHeaderFrame(first.value)
     const stream = await startMigrationStream(
       first.value,
+      sourceVersion,
       format,
       validateHistoricalHeader,
     )
@@ -557,7 +535,9 @@ async function verifyCurrentGeneration(
     generation.events,
     generation.meta,
     generation.inheritedEventCount,
+    'detached',
   )
+  assertCurrentAssistantStreams(generation.events)
   return {
     identity: snapshot.identity,
     bytes: snapshot.bytes.length,
@@ -565,6 +545,32 @@ async function verifyCurrentGeneration(
   }
 }
 
+/** Fully replay embedded streams only inside isolated current-generation verification. */
+function assertCurrentAssistantStreams(events: readonly SessionEvent[]): void {
+  for (const [index, event] of events.entries()) {
+    if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') continue
+    const assembler = new BlockAssembler()
+    let timed: ReturnType<typeof expandAssistantStream>
+    try {
+      timed = expandAssistantStream(event.data.stream)
+      for (const member of timed) assembler.push(member.chunk)
+    } catch (error: unknown) {
+      throw new Error(`seed ${event.type} at index ${index} has an invalid embedded stream`, { cause: error })
+    }
+    if (event.type === 'assistant/attempt' || timed.length === 0) continue
+    const content = event.data.interrupted === true ? assembler.interruptedBlocks() : assembler.blocks()
+    if (!isDeepStrictEqual(event.data.message.content, content)) {
+      throw new Error(`seed assistant/message at index ${index} content disagrees with its embedded stream`)
+    }
+    if (!isDeepStrictEqual(event.data.usage, assembler.usage)) {
+      throw new Error(`seed assistant/message at index ${index} usage disagrees with its embedded stream`)
+    }
+    if (!isDeepStrictEqual(event.data.message.source.replayState, assembler.replayState)) {
+      throw new Error(`seed assistant/message at index ${index} replay state disagrees with its embedded stream`)
+    }
+  }
+}
+
 function decodeCurrentGeneration(
   bytes: Buffer,
   compression: JsonlCompression,
@@ -620,45 +626,6 @@ function assertIndependentHeaderFrame(plaintext: Buffer): void {
   }
 }
 
-function readRawHeader(bytes: Buffer): JsonlPhysicalHeader {
-  const newline = bytes.indexOf(0x0A)
-  if (newline === -1) throw new Error('empty or header-less session log')
-  const record = bytes.subarray(0, newline + 1)
-  const value = parseJson(record.subarray(0, -1).toString('utf8'), 'header line')
-  storedVersion(value)
-  return { value: value as Record<string, unknown>, record }
-}
-
-function readZstdHeader(bytes: Buffer, signal?: AbortSignal): JsonlPhysicalHeader {
-  signal?.throwIfAborted()
-  const first = scanZstdFrames(bytes, 1).frames[0]
-  if (first === undefined) throw new Error('empty or header-less Zstandard session log')
-  const decoder = createZstdFrameDecoder()
-  const decodedFrames = decoder.decode(bytes, [first])
-  try {
-    const decoded = decodedFrames.next()
-    /* v8 ignore next -- one complete frame yields once or the decoder throws. */
-    if (decoded.done) throw new Error('empty or header-less Zstandard session log')
-    signal?.throwIfAborted()
-    assertIndependentHeaderFrame(decoded.value)
-    const record = Buffer.from(decoded.value)
-    const value = parseJson(record.subarray(0, -1).toString('utf8'), 'header line')
-    storedVersion(value)
-    return { value: value as Record<string, unknown>, record }
-  } finally {
-    decodedFrames.return()
-    decoder.close()
-  }
-}
-
-function readPhysicalHeader(
-  bytes: Buffer,
-  compression: JsonlCompression,
-  signal: AbortSignal | undefined,
-): JsonlPhysicalHeader {
-  return compression === 'zstd' ? readZstdHeader(bytes, signal) : readRawHeader(bytes)
-}
-
 function assertGenerationPaths(
   sourcePath: string,
   sourceVersion: number,
@@ -823,7 +790,6 @@ async function removeTemporary(
   try {
     await internals.fs.rm(path)
   } catch (cleanupFailure: unknown) {
-    if (primaryFailure === undefined) throw cleanupFailure
     throw new AggregateError(
       [primaryFailure, cleanupFailure],
       `failed to clean migration temporary "${path}" after an earlier failure`,
@@ -879,19 +845,16 @@ function asError(error: unknown): Error {
 
 async function inspectExpectedCurrent<T>(
   currentPath: string,
-  checkCanonicalTargetName: boolean,
   internals: JsonlGenerationInternals,
   inspect: () => Promise<T>,
 ): Promise<T> {
   try {
-    if (checkCanonicalTargetName) {
-      const expectedName = basename(currentPath)
-      const names = await internals.fs.readdir(dirname(currentPath))
-      if (!names.includes(expectedName)) {
-        const noncanonical = names.find(name => name.toLowerCase() === expectedName.toLowerCase())
-        if (noncanonical !== undefined) {
-          throw new Error(`target resolves to noncanonical directory entry "${noncanonical}"`)
-        }
+    const expectedName = basename(currentPath)
+    const names = await internals.fs.readdir(dirname(currentPath))
+    if (!names.includes(expectedName)) {
+      const noncanonical = names.find(name => name.toLowerCase() === expectedName.toLowerCase())
+      if (noncanonical !== undefined) {
+        throw new Error(`target resolves to noncanonical directory entry "${noncanonical}"`)
       }
     }
     const info = await internals.fs.lstat(currentPath)
@@ -913,39 +876,71 @@ function withOverrides(overrides: JsonlGenerationRuntimeOverrides): JsonlGenerat
   }
 }
 
-async function reopenExpectedCurrent(
-  currentPath: string,
-  staged: StreamedMigrationStage,
-  compression: JsonlCompression,
-  expectedId: string,
-  expectedEventCount: number,
-  verifyCurrentFile: EnsureJsonlGenerationOptions['verifyCurrentFile'],
-  signal: AbortSignal | undefined,
-  checkCanonicalTargetName: boolean,
+async function publishPreparedMigration(
+  options: PrepareJsonlMigrationOptions,
+  suffix: string,
+  artifact: SessionFormatArtifact,
+  sourceIdentity: JsonlPhysicalIdentity,
   internals: JsonlGenerationInternals,
-): Promise<JsonlPhysicalSnapshot> {
-  return inspectExpectedCurrent(currentPath, checkCanonicalTargetName, internals, async () => {
-    const verified = await verifyCurrentFile(
-      currentPath,
+): Promise<JsonlPhysicalIdentity> {
+  await scheduler.yield()
+  const { sourcePath, currentPath, compression, verifyCurrentFile } = options
+  const eventCount = artifact.events.length
+  let staged = await writeSyncedTemp(currentPath, suffix, compression, artifact, options.format, undefined, internals)
+  try {
+    const verifiedStage = await verifyCurrentFile(
+      staged.path,
       compression,
-      expectedId,
-      expectedEventCount,
-      staged,
-      signal,
+      artifact.header.id,
+      eventCount,
     )
-    if (verified.bytes !== staged.bytes || verified.digest !== staged.digest) {
-      throw new Error('target bytes differ from the migrated generation')
+    if (verifiedStage.bytes !== staged.bytes || verifiedStage.digest !== staged.digest) {
+      throw new Error('staged session generation changed during verification')
     }
-    const snapshot = await readStableSnapshot(currentPath, signal, internals.fs)
-    const header = readPhysicalHeader(snapshot.bytes, compression, signal)
-    return { ...snapshot, headerValue: header.value, headerRecord: header.record }
-  })
+    await internals.barrier('before-source-check', 1)
+    const beforePublish = await internals.fs.stat(sourcePath)
+    if (identity(beforePublish) !== identity(sourceIdentity)) {
+      throw new JsonlGenerationSourceChangedError(sourcePath)
+    }
+    const published = await publishCurrentExclusive(staged.path, currentPath, internals)
+    if (published && internals.platform === 'win32') staged = { ...staged, path: '' }
+    await internals.barrier('after-publication', 1)
+    let currentIdentity: JsonlPhysicalIdentity
+    if (published) {
+      if (staged.path !== '') {
+        await removeCommittedTemporary(staged.path, internals)
+        staged = { ...staged, path: '' }
+      }
+      currentIdentity = await internals.fs.stat(currentPath)
+    } else {
+      const winner = await inspectExpectedCurrent(currentPath, internals, async () => {
+        const candidate = await verifyCurrentFile(
+          currentPath,
+          compression,
+          artifact.header.id,
+          eventCount,
+          staged,
+        )
+        if (candidate.bytes !== staged.bytes || candidate.digest !== staged.digest) {
+          throw new Error('target bytes differ from the migrated generation')
+        }
+        return candidate
+      })
+      currentIdentity = winner.identity
+      await removeCommittedTemporary(staged.path, internals)
+      staged = { ...staged, path: '' }
+    }
+    return currentIdentity
+  } catch (error: unknown) {
+    if (staged.path !== '') await removeTemporary(staged.path, error, internals)
+    throw error
+  }
 }
 
-async function ensureCurrent(
-  options: EnsureJsonlGenerationOptions,
+async function prepareMigration(
+  options: PrepareJsonlMigrationOptions,
   internals: JsonlGenerationInternals,
-): Promise<EnsureJsonlGenerationResult> {
+): Promise<PreparedJsonlMigration> {
   const { sourcePath, sourceVersion, currentPath, compression, format, signal } = options
   const suffix = assertGenerationPaths(
     sourcePath,
@@ -954,124 +949,58 @@ async function ensureCurrent(
     format.currentVersion,
     compression,
   )
-  let attempt = 0
-  for (;;) {
-    attempt += 1
-    signal?.throwIfAborted()
-    const source = await readStableSnapshot(sourcePath, signal, internals.fs)
-    const quickHeader = readPhysicalHeader(source.bytes, compression, signal)
-    const quickVersion = storedVersion(quickHeader.value)
-    if (quickVersion !== sourceVersion) {
-      throw new Error(
-        `resolved JSONL source filename identifies v${sourceVersion}, but its header identifies v${quickVersion}: `
-        + sourcePath,
-      )
-    }
-    if (sourceVersion > format.currentVersion) {
-      throw new JsonlGenerationNewerVersionError(
-        sourceVersion,
-        format.currentVersion,
-        storedId(quickHeader.value),
-      )
-    }
-    if (sourceVersion === format.currentVersion) {
-      return {
-        status: 'current',
-        version: quickVersion,
-        path: sourcePath,
-        snapshot: {
-          ...source,
-          headerValue: quickHeader.value,
-          headerRecord: quickHeader.record,
-        },
-      }
-    }
-    let artifact: SessionFormatArtifact
-    try {
-      artifact = await decodeStreamingMigration(
-        source.bytes,
-        compression,
-        format,
-        options.validateHistoricalHeader,
-        signal,
-      )
-    } catch (error: unknown) {
-      if (format.isUnsupportedMigrationError?.(error) === true) {
-        throw new JsonlGenerationUnsupportedMigrationError(sourceVersion, error)
-      }
-      throw error
-    }
-    if (artifact.header.version !== format.currentVersion) {
-      throw new Error(`format migration returned v${artifact.header.version}, expected v${format.currentVersion}`)
+  if (sourceVersion >= format.currentVersion) {
+    throw new Error(`migration preparation requires a historical source, got v${sourceVersion}`)
+  }
+  const source = await readStableSnapshot(sourcePath, signal, internals.fs)
+  let artifact: SessionFormatArtifact
+  try {
+    artifact = await decodeStreamingMigration(
+      source.bytes,
+      compression,
+      sourceVersion,
+      format,
+      options.validateHistoricalHeader,
+      signal,
+    )
+  } catch (error: unknown) {
+    if (format.isUnsupportedMigrationError?.(error) === true) {
+      throw new JsonlGenerationUnsupportedMigrationError(sourceVersion, error)
     }
-
-    await scheduler.yield()
-    signal?.throwIfAborted()
-    const sourceFingerprint = fingerprint(source.identity, source.bytes)
-    const eventCount = artifact.events.length
-    let staged = await writeSyncedTemp(currentPath, suffix, compression, artifact, format, signal, internals)
-    let failure: unknown
-    try {
-      const verifiedStage = await options.verifyCurrentFile(
-        staged.path,
-        compression,
-        artifact.header.id,
-        eventCount,
-        undefined,
-        signal,
-      )
-      if (verifiedStage.bytes !== staged.bytes || verifiedStage.digest !== staged.digest) {
-        throw new Error('staged session generation changed during verification')
-      }
-      await internals.barrier('before-source-check', attempt)
-      const beforePublish = await readStableSnapshot(sourcePath, signal, internals.fs)
-      if (fingerprint(beforePublish.identity, beforePublish.bytes) !== sourceFingerprint) continue
-
-      const published = await publishCurrentExclusive(staged.path, currentPath, internals)
-      if (published && internals.platform === 'win32') staged = { ...staged, path: '' }
-      await internals.barrier('after-publication', attempt)
-      signal?.throwIfAborted()
-      const committed = await reopenExpectedCurrent(
-        currentPath,
-        staged,
-        compression,
-        artifact.header.id,
-        eventCount,
-        options.verifyCurrentFile,
-        signal,
-        !published,
-        internals,
-      )
-      if (staged.path !== '') {
-        await removeCommittedTemporary(staged.path, internals)
-        staged = { ...staged, path: '' }
-      }
-      return {
-        status: 'migrated',
-        fromVersion: sourceVersion,
-        toVersion: format.currentVersion,
-        path: currentPath,
-        sourcePath,
-        snapshot: committed,
+    throw error
+  }
+  if (artifact.header.version !== format.currentVersion) {
+    throw new Error(`format migration returned v${artifact.header.version}, expected v${format.currentVersion}`)
+  }
+  const sourceIdentity = source.identity
+  let publication: Promise<JsonlPhysicalIdentity> | undefined
+  return {
+    sourceIdentity,
+    artifact,
+    publish() {
+      if (publication === undefined) {
+        publication = publishPreparedMigration(
+          options,
+          suffix,
+          artifact,
+          sourceIdentity,
+          internals,
+        )
       }
-    } catch (error: unknown) {
-      failure = error
-      throw error
-    } finally {
-      if (staged.path !== '') await removeTemporary(staged.path, failure, internals)
-    }
+      return publication
+    },
   }
 }
 
 /**
- * Ensure one resolved generation has a current-format successor before returning.
- * @param options - resolved source, current target, format adapter, verification, and cancellation.
- * @returns the current source or the verified and reopened migrated successor.
+ * Decode and migrate one historical generation without writing its successor.
+ * @param options - resolved source, current target, format adapter, and load cancellation.
+ * @returns the current artifact and an idempotent explicit publication operation.
  */
-export function ensureJsonlGenerationCurrent(
-  options: EnsureJsonlGenerationOptions,
-): Promise<EnsureJsonlGenerationResult> {
-  return defaultGenerationRuntime.ensure(options)
+export function prepareJsonlMigration(
+  options: PrepareJsonlMigrationOptions,
+): Promise<PreparedJsonlMigration> {
+  return defaultGenerationRuntime.prepare(options)
 }
 
 /**
@@ -1085,7 +1014,7 @@ export function createJsonlGenerationRuntime(
   const internals = withOverrides(overrides)
   return {
     readStable: (path, signal) => readStableSnapshot(path, signal, internals.fs),
-    ensure: options => ensureCurrent(options, internals),
+    prepare: options => prepareMigration(options, internals),
     verify: (path, compression, expectedId, expectedEventCount, expectedPrefix) => verifyCurrentGeneration(
       path, compression, expectedId, expectedEventCount, internals.fs, expectedPrefix,
     ),

+ 314 - 66
packages/session/session-persistence-jsonl/src/index.ts

@@ -24,12 +24,13 @@ import {
   SessionAlreadyExistsError, SessionPersistenceNotFoundError,
   assertStoredId, materializeCreateHeader, sessionFormatVersionRefusal, validateStoredEvents,
   type SessionAccess, type SessionHandle,
+  type SessionHandleReadResult,
   type SessionLocation, type SessionPersistenceCreateOptions,
   type SessionPersistenceListOptions, type SessionPersistenceOpenOptions,
   type SessionPersistenceSnapshot, type SessionPersistenceStatOptions,
   type SessionPersistenceRevision as PersistenceRevision,
 } from '@deepseek-ai/dsh-session-persistence'
-import { JsonlBackendTracker, JsonlSessionHandle } from './storage.ts'
+import { JsonlBackendTracker, JsonlSessionHandle, type StorageHandleState } from './storage.ts'
 import { SessionWriteLease } from './lease.ts'
 import { SESSION_FORMAT_VERSION, SessionId as makeSessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionId, SessionHeader, SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session'
@@ -44,13 +45,13 @@ import {
 import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts'
 import { verifyCurrentGenerationInWorker } from './migration-verifier.ts'
 import {
-  ensureJsonlGenerationCurrent,
-  JsonlGenerationNewerVersionError,
+  JsonlGenerationSourceChangedError,
   JsonlGenerationUnsupportedMigrationError,
+  prepareJsonlMigration,
   readStableJsonlFile,
-  type EnsureJsonlGenerationResult,
   type JsonlGenerationFormatAdapter,
   type JsonlPhysicalIdentity,
+  type PreparedJsonlMigration,
 } from './generation.ts'
 
 export type { JsonlCompression } from './format.ts'
@@ -97,11 +98,14 @@ export interface Config {
   compression?: JsonlCompression
 }
 
-/** A parsed, validated stored log: header, logical events, and any torn-tail repair state. */
-interface StoredLog {
+/** One stored event graph whose producer has established immutable sharing. */
+interface FrozenStoredEvents extends SessionHandleReadResult {
+  readonly eventState: 'shared-frozen'
+}
+
+/** State shared by prepared historical and published current logs. */
+interface StoredLogBase extends FrozenStoredEvents {
   readonly meta: SessionHeader
-  /** The logical log, including any events recovered from a torn final frame. */
-  readonly events: SessionEvent[]
   readonly tornTruncateTo: number | undefined
   /** Complete events recovered from the torn final frame; the write path rewrites them durably. */
   readonly recoveredTail: SessionEvent[]
@@ -110,6 +114,45 @@ interface StoredLog {
   readonly revision: PersistenceRevision
 }
 
+/** A decoded current generation that is already durable. */
+interface CurrentStoredLog extends StoredLogBase {
+  readonly status: 'current'
+}
+
+/** A migrated historical generation retained until an explicit write open publishes it. */
+interface PreparedStoredLog extends StoredLogBase {
+  readonly status: 'prepared'
+  readonly publication: {
+    readonly source: ResolvedJsonlGeneration
+    readonly value: PreparedJsonlMigration
+  }
+}
+
+/** A validated logical log, either durable current state or prepared historical state. */
+type StoredLog = CurrentStoredLog | PreparedStoredLog
+
+/** Deep-freeze one acyclic stored JSON event without recursive calls. */
+function freezeStoredEvent(event: SessionEvent): void {
+  const pending: object[] = [event]
+  while (pending.length > 0) {
+    // The non-empty check proves an object remains to visit.
+    // oxlint-disable-next-line typescript/no-non-null-assertion
+    const current = pending.pop()!
+    Object.freeze(current)
+    for (const key in current) {
+      const child = (current as Record<string, unknown>)[key]
+      if (child !== null && typeof child === 'object') pending.push(child)
+    }
+  }
+}
+
+/** Establish immutable sharing for one decoded event graph and report that state. */
+function freezeStoredEvents(events: SessionEvent[]): FrozenStoredEvents {
+  for (const event of events) freezeStoredEvent(event)
+  Object.freeze(events)
+  return { eventState: 'shared-frozen', events }
+}
+
 /** One authoritative immutable generation selected from a Session directory. */
 interface ResolvedJsonlGeneration {
   readonly sourcePath: string
@@ -117,6 +160,16 @@ interface ResolvedJsonlGeneration {
   readonly currentPath: string
 }
 
+/** One backend-owned historical preparation shared by its current callers. */
+interface MigrationPreparation {
+  readonly sourcePath: string
+  readonly sourceRevision: PersistenceRevision
+  readonly controller: AbortController
+  readonly promise: Promise<PreparedStoredLog>
+  settled: boolean
+  waiters: number
+}
+
 /** Build the stat-derived best-effort change token shared by full and lightweight reads. */
 function fileRevision(identity: JsonlPhysicalIdentity): PersistenceRevision {
   return SessionPersistenceRevision([
@@ -138,6 +191,41 @@ function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
   return typeof (error as NodeJS.ErrnoException | null)?.code === 'string'
 }
 
+/** Preserve an Error abort reason and normalize hostile non-Error reasons. */
+function abortError(signal: AbortSignal): Error {
+  return signal.reason instanceof Error
+    ? signal.reason
+    : new Error('session migration preparation aborted', { cause: signal.reason })
+}
+
+/** Let one caller stop waiting without transferring cancellation ownership to shared work. */
+function waitWithAbort<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
+  if (signal === undefined) return operation
+  /* v8 ignore next -- requireStoredLog synchronously rechecks the signal immediately before waiting. */
+  if (signal.aborted) return Promise.reject(abortError(signal))
+  return new Promise<T>((resolve, reject) => {
+    const stopWaiting = (): void => {
+      reject(abortError(signal))
+    }
+    signal.addEventListener('abort', stopWaiting, { once: true })
+    void operation.then(
+      (value) => {
+        signal.removeEventListener('abort', stopWaiting)
+        resolve(value)
+      },
+      (error: unknown) => {
+        signal.removeEventListener('abort', stopWaiting)
+        /* v8 ignore else -- the preparation owner normalizes every rejection before this waiter sees it. */
+        if (error instanceof Error) {
+          reject(error)
+        } else {
+          reject(new Error('session migration preparation failed', { cause: error }))
+        }
+      },
+    )
+  })
+}
+
 /**
  * The JSONL persistence backend. Load as a plugin; it registers as
  * `ctx.sessionPersistence`. Sessions materialize lazily: a created session is
@@ -166,6 +254,8 @@ class JsonlSessionPersistence extends SessionPersistence {
    * revision guard.
    */
   private readonly coldLogMemo = new Map<SessionId, StoredLog>()
+  /** One joinable decode/migration operation per selected historical Session file revision. */
+  private readonly migrationPreparations = new Map<SessionId, MigrationPreparation>()
 
   constructor(ctx: Context, public config: Config) {
     super(ctx)
@@ -253,11 +343,22 @@ class JsonlSessionPersistence extends SessionPersistence {
         return this.tracker.adopt(new JsonlSessionHandle(this, id, pending.header, 'read', { cursor: 0, materialized: false, inheritedEventCount: pending.inheritedEventCount }))
       }
       const stored = await this.requireStoredLog(id, options?.signal)
-      return this.tracker.adopt(new JsonlSessionHandle(this, id, stored.meta, 'read', {
-        cursor: 0,
-        materialized: true,
-        inheritedEventCount: stored.inheritedEventCount,
-      }))
+      let state: StorageHandleState
+      if (stored.status === 'prepared') {
+        state = {
+          cursor: 0,
+          materialized: true,
+          inheritedEventCount: stored.inheritedEventCount,
+          primed: stored,
+        }
+      } else {
+        state = {
+          cursor: 0,
+          materialized: true,
+          inheritedEventCount: stored.inheritedEventCount,
+        }
+      }
+      return this.tracker.adopt(new JsonlSessionHandle(this, id, stored.meta, 'read', state))
     }
     // A pending entry always belongs to an ACTIVE creator handle (close erases
     // it), so the claim below rejects that case as already owned.
@@ -267,14 +368,22 @@ class JsonlSessionPersistence extends SessionPersistence {
       const resolved = await this.findLog(id, options?.signal)
       if (resolved === undefined) throw new SessionPersistenceNotFoundError(id)
       lease = await this.acquireLease(id, undefined, dirname(resolved.currentPath))
-      const stored = await this.requireStoredLog(id, options?.signal)
+      const prepared = await this.requireStoredLog(id, options?.signal)
+      options?.signal?.throwIfAborted()
+      let stored: CurrentStoredLog
+      if (prepared.status === 'prepared') {
+        stored = await this.publishStoredMigration(id, prepared)
+      } else {
+        stored = prepared
+      }
+      options?.signal?.throwIfAborted()
       return this.tracker.adopt(new JsonlSessionHandle(this, id, stored.meta, 'write', {
         cursor: stored.events.length,
         materialized: true,
         tornTruncateTo: stored.tornTruncateTo,
         recoveredTail: stored.recoveredTail,
         inheritedEventCount: stored.inheritedEventCount,
-        primed: stored.events,
+        primed: stored,
       }, lease))
     } catch (error) {
       // Free the in-process claim no matter how the kernel-lock release
@@ -386,34 +495,115 @@ class JsonlSessionPersistence extends SessionPersistence {
   private async requireStoredLog(id: SessionId, signal?: AbortSignal): Promise<StoredLog> {
     const selected = await this.findLog(id, signal)
     if (selected === undefined) throw new SessionPersistenceNotFoundError(id)
-    if (selected.sourceVersion === SESSION_FORMAT_VERSION) {
-      const probe = fileRevision(await stat(selected.sourcePath, { bigint: true }))
-      const memoized = this.coldLogMemo.get(id)
-      if (memoized !== undefined && memoized.revision === probe) {
-        this.coldLogMemo.delete(id)
-        this.coldLogMemo.set(id, memoized)
-        return memoized
+    if (selected.sourceVersion < SESSION_FORMAT_VERSION) {
+      const sourceRevision = fileRevision(await stat(selected.sourcePath, { bigint: true }))
+      signal?.throwIfAborted()
+      let preparation = this.migrationPreparations.get(id)
+      if (preparation === undefined
+        || preparation.sourcePath !== selected.sourcePath
+        || preparation.sourceRevision !== sourceRevision) {
+        const controller = new AbortController()
+        const promise = this.loadStoredMigration(id, selected, sourceRevision, controller.signal)
+        preparation = {
+          sourcePath: selected.sourcePath,
+          sourceRevision,
+          controller,
+          promise,
+          settled: false,
+          waiters: 0,
+        }
+        this.migrationPreparations.set(id, preparation)
+        const created = preparation
+        const release = (): void => {
+          created.settled = true
+          if (this.migrationPreparations.get(id) === created) {
+            this.migrationPreparations.delete(id)
+          }
+        }
+        void promise.then(release, release)
       }
+      signal?.throwIfAborted()
+      return this.waitForPreparation(id, preparation, signal)
+    }
+    if (selected.sourceVersion > SESSION_FORMAT_VERSION) {
+      const header = await this.readGenerationHeader(selected, id, signal)
+      /* v8 ignore else -- a readable future header is rejected inside readGenerationHeader. */
+      if (header === undefined) {
+        throw new SessionPersistenceCorruptionError(
+          `session "${id}": stored log has a malformed header (raw log: ${selected.sourcePath})`,
+          { cause: new Error('malformed Session header') },
+        )
+      }
+      /* v8 ignore next -- readGenerationHeader rejects every future version. */
+      throw new SessionFormatUnsupportedError(
+        `${sessionFormatVersionRefusal(id, selected.sourceVersion)} (raw log: ${selected.sourcePath})`,
+        { kind: 'jsonl', path: selected.sourcePath },
+      )
     }
-    const current = await this.ensureCurrentLog(id, signal, selected)
+    const probe = fileRevision(await stat(selected.sourcePath, { bigint: true }))
+    const memoized = this.coldLogMemo.get(id)
+    if (memoized?.status === 'current' && memoized.revision === probe) {
+      this.coldLogMemo.delete(id)
+      this.coldLogMemo.set(id, memoized)
+      return memoized
+    }
+    const current = await readStableJsonlFile(selected.sourcePath, signal)
     return this.decodeStoredLog(
-      current.path,
+      selected.sourcePath,
       id,
-      current.snapshot.bytes,
-      fileRevision(current.snapshot.identity),
+      current.bytes,
+      fileRevision(current.identity),
       signal,
     )
   }
 
-  /** Select and, when required, publish one immutable current generation. */
-  private async ensureCurrentLog(
+  /** Probe the memo and otherwise decode one historical generation under backend cancellation. */
+  private async loadStoredMigration(
     id: SessionId,
-    signal: AbortSignal | undefined,
     selected: ResolvedJsonlGeneration,
-  ): Promise<EnsureJsonlGenerationResult> {
-    signal?.throwIfAborted()
+    sourceRevision: PersistenceRevision,
+    signal: AbortSignal,
+  ): Promise<PreparedStoredLog> {
+    signal.throwIfAborted()
+    const memoized = this.coldLogMemo.get(id)
+    if (memoized?.status === 'prepared' && memoized.revision === sourceRevision) {
+      this.coldLogMemo.delete(id)
+      this.coldLogMemo.set(id, memoized)
+      return memoized
+    }
+    return this.prepareStoredMigration(id, selected, signal)
+  }
+
+  /** Await shared preparation for one caller and abort it only after its last waiter leaves. */
+  private async waitForPreparation(
+    id: SessionId,
+    preparation: MigrationPreparation,
+    signal?: AbortSignal,
+  ): Promise<PreparedStoredLog> {
+    preparation.waiters += 1
     try {
-      return await ensureJsonlGenerationCurrent({
+      return await waitWithAbort(preparation.promise, signal)
+    } finally {
+      preparation.waiters -= 1
+      if (preparation.waiters === 0 && !preparation.settled) {
+        /* v8 ignore else -- a newer selected source may already own this id's preparation slot. */
+        if (this.migrationPreparations.get(id) === preparation) {
+          this.migrationPreparations.delete(id)
+        }
+        preparation.controller.abort()
+      }
+    }
+  }
+
+  /** Decode one historical generation without publishing a successor. */
+  private async prepareStoredMigration(
+    id: SessionId,
+    selected: ResolvedJsonlGeneration,
+    signal: AbortSignal,
+  ): Promise<PreparedStoredLog> {
+    let prepared: Awaited<ReturnType<typeof prepareJsonlMigration>>
+    try {
+      prepared = await prepareJsonlMigration({
         sourcePath: selected.sourcePath,
         sourceVersion: selected.sourceVersion,
         currentPath: selected.currentPath,
@@ -426,32 +616,75 @@ class JsonlSessionPersistence extends SessionPersistence {
           id,
           signal,
         ),
-        ...(signal === undefined ? {} : { signal }),
+        signal,
       })
     } catch (error: unknown) {
-      signal?.throwIfAborted()
-      if (error instanceof JsonlGenerationNewerVersionError) {
-        const reason = sessionFormatVersionRefusal(error.storedId, error.storedVersion)
-        throw new SessionFormatUnsupportedError(
-          `${reason} (raw log: ${selected.sourcePath})`,
-          { kind: 'jsonl', path: selected.sourcePath },
-        )
-      }
-      if (error instanceof JsonlGenerationUnsupportedMigrationError) {
-        throw new SessionFormatUnsupportedError(
-          `${error.message}; source v${error.fromVersion} artifact remains unchanged (raw log: ${selected.sourcePath})`,
-          { kind: 'jsonl', path: selected.sourcePath },
-        )
-      }
-      if (error instanceof SessionFormatUnsupportedError
-        || error instanceof SessionPersistenceCorruptionError
-        || isErrnoException(error)
-        || error instanceof DOMException && error.name === 'AbortError') throw error
-      throw new SessionPersistenceCorruptionError(
-        `session "${id}": stored log is corrupt: ${String(error)} (raw log: ${selected.sourcePath})`,
-        { cause: error },
+      throw this.generationFailure(id, selected, error)
+    }
+    const meta = this.currentHeader(prepared.artifact.header)
+    assertStoredId(id, meta)
+    const events = prepared.artifact.events as SessionEvent[]
+    validateStoredEvents(meta, events, { kind: 'jsonl', path: selected.sourcePath })
+    const stored: PreparedStoredLog = {
+      status: 'prepared',
+      meta,
+      ...freezeStoredEvents(events),
+      tornTruncateTo: undefined,
+      recoveredTail: [],
+      inheritedEventCount: SessionLogOffset(prepared.artifact.inheritedEventCount),
+      revision: fileRevision(prepared.sourceIdentity),
+      publication: { source: selected, value: prepared },
+    }
+    this.memoizeStoredLog(id, stored)
+    return stored
+  }
+
+  /** Publish a prepared historical log before granting write access. */
+  private async publishStoredMigration(id: SessionId, stored: PreparedStoredLog): Promise<CurrentStoredLog> {
+    const migration = stored.publication
+    let identity: JsonlPhysicalIdentity
+    try {
+      identity = await migration.value.publish()
+    } catch (error: unknown) {
+      /* v8 ignore else -- a newer preparation may have replaced this stale cache entry. */
+      if (this.coldLogMemo.get(id) === stored) this.coldLogMemo.delete(id)
+      throw this.generationFailure(id, migration.source, error)
+    }
+    const published: CurrentStoredLog = {
+      status: 'current',
+      meta: stored.meta,
+      eventState: stored.eventState,
+      events: stored.events,
+      tornTruncateTo: stored.tornTruncateTo,
+      recoveredTail: stored.recoveredTail,
+      inheritedEventCount: stored.inheritedEventCount,
+      revision: fileRevision(identity),
+    }
+    this.memoizeStoredLog(id, published)
+    return published
+  }
+
+  /** Translate generation-layer failures into the persistence seam's error vocabulary. */
+  private generationFailure(
+    id: SessionId,
+    selected: ResolvedJsonlGeneration,
+    error: unknown,
+  ): Error {
+    if (error instanceof JsonlGenerationUnsupportedMigrationError) {
+      return new SessionFormatUnsupportedError(
+        `${error.message}; source v${error.fromVersion} artifact remains unchanged (raw log: ${selected.sourcePath})`,
+        { kind: 'jsonl', path: selected.sourcePath },
       )
     }
+    if (error instanceof JsonlGenerationSourceChangedError) return error
+    if (error instanceof SessionFormatUnsupportedError
+      || error instanceof SessionPersistenceCorruptionError
+      || isErrnoException(error)
+      || error instanceof DOMException && error.name === 'AbortError') return error
+    return new SessionPersistenceCorruptionError(
+      `session "${id}": stored log is corrupt: ${String(error)} (raw log: ${selected.sourcePath})`,
+      { cause: error },
+    )
   }
 
   /**
@@ -461,11 +694,11 @@ class JsonlSessionPersistence extends SessionPersistence {
    * @param signal - optional cancellation for the stat/read/decode work.
    * @returns the validated stored log with any torn-tail truncation point.
    */
-  async readStoredLog(path: string, expectedId: SessionId, signal?: AbortSignal): Promise<StoredLog> {
+  async readStoredLog(path: string, expectedId: SessionId, signal?: AbortSignal): Promise<CurrentStoredLog> {
     signal?.throwIfAborted()
     const probe = fileRevision(await stat(path, { bigint: true }))
     const memoized = this.coldLogMemo.get(expectedId)
-    if (memoized !== undefined && memoized.revision === probe) {
+    if (memoized?.status === 'current' && memoized.revision === probe) {
       this.coldLogMemo.delete(expectedId)
       this.coldLogMemo.set(expectedId, memoized)
       return memoized
@@ -481,7 +714,7 @@ class JsonlSessionPersistence extends SessionPersistence {
     buffer: Buffer,
     revision: PersistenceRevision,
     signal?: AbortSignal,
-  ): Promise<StoredLog> {
+  ): Promise<CurrentStoredLog> {
     let parsed: {
       meta: SessionHeader
       inheritedEventCount: SessionLogOffsetType
@@ -523,30 +756,45 @@ class JsonlSessionPersistence extends SessionPersistence {
     assertStoredId(expectedId, parsed.meta)
     const location = this.locate(parsed.meta)
     validateStoredEvents(parsed.meta, parsed.events, location)
-    const stored: StoredLog = { ...parsed, revision }
-    this.coldLogMemo.delete(expectedId)
-    this.coldLogMemo.set(expectedId, stored)
+    const { events, ...rest } = parsed
+    const stored: CurrentStoredLog = {
+      status: 'current',
+      ...rest,
+      ...freezeStoredEvents(events),
+      revision,
+    }
+    this.memoizeStoredLog(expectedId, stored)
+    return stored
+  }
+
+  /** Insert one parsed log into the bounded handoff cache. */
+  private memoizeStoredLog(id: SessionId, stored: StoredLog): void {
+    this.coldLogMemo.delete(id)
+    this.coldLogMemo.set(id, stored)
     for (const oldest of this.coldLogMemo.keys()) {
       if (this.coldLogMemo.size <= COLD_LOG_MEMO_MAX_ENTRIES) break
       this.coldLogMemo.delete(oldest)
     }
-    return stored
   }
 
   /**
-   * Resolve a session's unique log path.
+   * Resolve a session's current-generation log path.
    * @param id - the stored session to locate.
    * @param signal - optional cancellation for the directory scans.
-   * @returns the artifact path, or `undefined` when absent.
+   * @returns the current artifact path, or `undefined` while only a historical generation exists.
    */
-  async resolveLog(id: SessionId, signal?: AbortSignal): Promise<string | undefined> {
+  async resolveCurrentLog(id: SessionId, signal?: AbortSignal): Promise<string | undefined> {
     await this.ensureRootEncoding()
     signal?.throwIfAborted()
     const selected = await this.findLog(id, signal)
     if (selected === undefined) return undefined
     if (selected.sourceVersion === SESSION_FORMAT_VERSION) return selected.sourcePath
-    const current = await this.ensureCurrentLog(id, signal, selected)
-    return current.path
+    if (selected.sourceVersion < SESSION_FORMAT_VERSION) return undefined
+    const reason = sessionFormatVersionRefusal(id, selected.sourceVersion)
+    throw new SessionFormatUnsupportedError(
+      `${reason} (raw log: ${selected.sourcePath})`,
+      { kind: 'jsonl', path: selected.sourcePath },
+    )
   }
 
   /**

+ 56 - 22
packages/session/session-persistence-jsonl/src/storage.ts

@@ -28,6 +28,7 @@ import type {
   SessionHandleAppendOptions,
   SessionHandleFlushOptions,
   SessionHandleReadOptions,
+  SessionHandleReadResult,
 } from '@deepseek-ai/dsh-session-persistence'
 import type { SessionWriteLease } from './lease.ts'
 
@@ -47,10 +48,10 @@ export interface JsonlHandleStorage {
   persistHeader(header: SessionHeader, inheritedEventCount: SessionLogOffset): Promise<void>
   /** Truncate a torn physical tail before the first new append lands. */
   truncateTornTail(header: SessionHeader, truncateTo: number): Promise<void>
-  /** Resolve the session's artifact path, or `undefined` before materialization. */
-  resolveLog(id: SessionId, signal?: AbortSignal): Promise<string | undefined>
-  /** Read and validate the stored log at `path`. */
-  readStoredLog(path: string, expectedId: SessionId, signal?: AbortSignal): Promise<{ events: SessionEvent[] }>
+  /** Resolve the current-generation artifact path, or `undefined` when absent. */
+  resolveCurrentLog(id: SessionId, signal?: AbortSignal): Promise<string | undefined>
+  /** Read and validate the stored log at `path`, including its established event aliasing state. */
+  readStoredLog(path: string, expectedId: SessionId, signal?: AbortSignal): Promise<SessionHandleReadResult>
   /** Whether the id is still a created-but-unmaterialized session here. */
   hasPendingSession(id: SessionId): boolean
   /** Acquire the session's cross-process write lock in its artifact directory. */
@@ -72,7 +73,7 @@ export interface StorageHandleState {
   /** Exact fork-inherited prefix length stored with the log; `0` when unseeded. */
   inheritedEventCount: SessionLogOffset
   /** The validated stored prefix from a write open, served to reads until the first append. */
-  primed?: SessionEvent[] | undefined
+  primed?: SessionHandleReadResult | undefined
 }
 
 /**
@@ -112,9 +113,9 @@ export class JsonlSessionHandle implements SessionHandle {
    * @param offset - first logical seq to include (default 0).
    * @param length - maximum events returned (default: the rest).
    * @param options - optional cancellation.
-   * @returns the requested slice.
+   * @returns a slice carrying the aliasing state established by its producer.
    */
-  async read(offset = 0, length = Number.MAX_SAFE_INTEGER, options?: SessionHandleReadOptions): Promise<readonly SessionEvent[]> {
+  async read(offset = 0, length = Number.MAX_SAFE_INTEGER, options?: SessionHandleReadOptions): Promise<SessionHandleReadResult> {
     // Closed-handle refusal precedes argument validation: a closed handle
     // rejects SessionHandleClosedError regardless of the arguments.
     this.assertOpen('read')
@@ -125,24 +126,57 @@ export class JsonlSessionHandle implements SessionHandle {
       throw new TypeError(`read length must be a non-negative safe integer, got ${String(length)}`)
     }
     options?.signal?.throwIfAborted()
-    if (this.state.primed !== undefined) {
-      this.observedLength = Math.max(this.observedLength, this.state.primed.length)
-      return this.state.primed.slice(offset, offset + length)
+    let result: SessionHandleReadResult
+    const primed = this.state.primed
+    if (primed !== undefined) {
+      if (this.access === 'write') {
+        result = this.readPrimed(primed, offset, length)
+      } else {
+        const currentPath = await this.storage.resolveCurrentLog(this.id, options?.signal)
+        if (currentPath === undefined) {
+          result = this.readPrimed(primed, offset, length)
+        } else {
+          this.state.primed = undefined
+          result = await this.readCurrent(currentPath, offset, length, options?.signal)
+        }
+      }
+    } else if (this.access === 'write' && !this.state.materialized) {
+      result = { eventState: 'detached', events: [] }
+    } else {
+      const currentPath = await this.storage.resolveCurrentLog(this.id, options?.signal)
+      if (currentPath !== undefined) {
+        result = await this.readCurrent(currentPath, offset, length, options?.signal)
+      } else if (this.storage.hasPendingSession(this.id)) {
+        result = { eventState: 'detached', events: [] }
+      } else {
+        throw new SessionPersistenceNotFoundError(this.id)
+      }
     }
-    // A write handle knows its own materialization; a read handle asks the
-    // backend so a writer's later materialization becomes visible here.
-    if (this.access === 'write' && !this.state.materialized) return []
-    const path = await this.storage.resolveLog(this.id, options?.signal)
-    if (path === undefined) {
-      if (this.storage.hasPendingSession(this.id)) return []
-      throw new SessionPersistenceNotFoundError(this.id)
+    return result
+  }
+
+  /** Read one slice from the prepared historical prefix retained by this handle. */
+  private readPrimed(source: SessionHandleReadResult, offset: number, length: number): SessionHandleReadResult {
+    this.observedLength = Math.max(this.observedLength, source.events.length)
+    return { eventState: source.eventState, events: source.events.slice(offset, offset + length) }
+  }
+
+  /** Read one current physical generation and enforce this handle's monotonic view. */
+  private async readCurrent(
+    path: string,
+    offset: number,
+    length: number,
+    signal?: AbortSignal,
+  ): Promise<SessionHandleReadResult> {
+    const source = await this.storage.readStoredLog(path, this.id, signal)
+    if (source.events.length < this.observedLength) {
+      throw new Error(`session "${this.id}": stored log shrank below a previously observed prefix (${source.events.length} < ${this.observedLength})`)
     }
-    const { events } = await this.storage.readStoredLog(path, this.id, options?.signal)
-    if (events.length < this.observedLength) {
-      throw new Error(`session "${this.id}": stored log shrank below a previously observed prefix (${events.length} < ${this.observedLength})`)
+    this.observedLength = source.events.length
+    return {
+      eventState: source.eventState,
+      events: source.events.slice(offset, offset + length),
     }
-    this.observedLength = events.length
-    return events.slice(offset, offset + length)
   }
 
   /**

+ 1 - 1
packages/session/session-persistence-jsonl/tests/built-migration-worker.e2e.ts

@@ -27,7 +27,7 @@ describe.skipIf(!built)('built migration verifier (plain node)', () => {
           type: 'session', version: 0, id, createdAt: 1, delegationDepth: 0,
         }) + '\\n')
         await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
-        const handle = await ctx.sessionPersistence.open(id, 'read')
+        const handle = await ctx.sessionPersistence.open(id, 'write')
         await handle.close()
         await ctx.sessionPersistence.flush()
         const header = JSON.parse((await readFile(join(directory, 'session.v2.jsonl'), 'utf8')).trim())

+ 212 - 181
packages/session/session-persistence-jsonl/tests/generation.spec.ts

@@ -17,19 +17,24 @@ import {
 import { tmpdir } from 'node:os'
 import { basename, join } from 'node:path'
 import { performance } from 'node:perf_hooks'
-import { scheduler } from 'node:timers/promises'
 import {
-  ensureJsonlGenerationCurrent as ensureJsonlGenerationCurrentProduction,
+  JsonlGenerationSourceChangedError,
   JsonlGenerationTargetConflictError,
   JsonlGenerationUnsupportedMigrationError,
+  prepareJsonlMigration,
   verifyJsonlCurrentGeneration,
-  type EnsureJsonlGenerationOptions,
   type JsonlGenerationFormatAdapter,
+  type PrepareJsonlMigrationOptions,
 } from '../src/generation.ts'
 import { createJsonlGenerationTestRuntime } from '../src/testing/generation.ts'
 import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from '../src/zstd.ts'
 import type { JsonlCompression } from '../src/format.ts'
-import type { SessionFormatArtifact, SessionFormatRestore } from '@deepseek-ai/dsh-session-format'
+import type {
+  SessionFormatArtifact,
+  SessionFormatEvent,
+  SessionFormatJsonValue,
+  SessionFormatRestore,
+} from '@deepseek-ai/dsh-session-format'
 
 const roots: string[] = []
 
@@ -78,6 +83,61 @@ function header(version: number, id = 'generation-test'): Record<string, unknown
 
 const event0 = { type: 'turn/start', seq: 0, time: 2, data: { turn: 1 } }
 const event1 = { type: 'turn/end', seq: 1, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }
+const assistantUsage = { inputTokens: 3, outputTokens: 2 }
+const assistantReplayState = { response: { id: 'response' } }
+
+function assistantData(
+  overrides: {
+    readonly content?: readonly SessionFormatJsonValue[]
+    readonly stream?: SessionFormatJsonValue
+    readonly usage?: SessionFormatJsonValue
+    readonly replayState?: SessionFormatJsonValue
+    readonly interrupted?: true
+  } = {},
+): SessionFormatJsonValue {
+  const replayState = overrides.replayState === undefined
+    ? assistantReplayState
+    : overrides.replayState
+  return {
+    turn: 1,
+    step: 1,
+    message: {
+      id: 'assistant',
+      role: 'assistant',
+      content: overrides.content ?? [{ type: 'text', text: 'hello' }],
+      source: {
+        kind: 'model', provider: 'mock', model: 'mock',
+        ...(replayState === null ? {} : { replayState }),
+      },
+    },
+    stream: overrides.stream ?? [
+      { type: 'text-chunks', time0: 3, index: 0, dt: [], texts: ['hello'] },
+      { type: 'chunk', time: 4, chunk: { type: 'usage', usage: assistantUsage } },
+      { type: 'chunk', time: 5, chunk: { type: 'finish', reason: { kind: 'stop' }, replayState: assistantReplayState } },
+    ],
+    ...(overrides.usage === null ? {} : { usage: overrides.usage ?? assistantUsage }),
+    ...(overrides.interrupted === undefined ? {} : { interrupted: overrides.interrupted }),
+  }
+}
+
+function assistantLifecycle(
+  type: 'assistant/message' | 'assistant/attempt',
+  data: SessionFormatJsonValue,
+): SessionFormatEvent[] {
+  return [
+    { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+    { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
+    {
+      type,
+      seq: 2,
+      time: 5,
+      data,
+      ...(type === 'assistant/message' ? { surfaceOp: 'append' as const } : {}),
+    },
+    { type: 'step/end', seq: 3, time: 6, data: { turn: 1, step: 1 } },
+    { type: 'turn/end', seq: 4, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
+  ]
+}
 
 interface TestGenerationFormatAdapter extends JsonlGenerationFormatAdapter {
   createRestore(header: Record<string, unknown>): SessionFormatRestore
@@ -120,12 +180,12 @@ function streamingAdapter(): JsonlGenerationFormatAdapter & {
   return adapter()
 }
 
-function verifier(): EnsureJsonlGenerationOptions['verifyCurrentFile'] {
+function verifier(): PrepareJsonlMigrationOptions['verifyCurrentFile'] {
   return (path, compression, expectedId, expectedEventCount, expectedPrefix) =>
     verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount, expectedPrefix)
 }
 
-const byteVerifier: EnsureJsonlGenerationOptions['verifyCurrentFile'] = async (path) => {
+const byteVerifier: PrepareJsonlMigrationOptions['verifyCurrentFile'] = async (path) => {
   const [bytes, identity] = await Promise.all([readFile(path), stat(path, { bigint: true })])
   return {
     identity,
@@ -144,7 +204,7 @@ function options(
   compression: JsonlCompression = 'none',
   format: JsonlGenerationFormatAdapter = adapter(),
   sourceVersion = 0,
-): Omit<EnsureJsonlGenerationOptions, 'verifyCurrentFile'> {
+): Omit<PrepareJsonlMigrationOptions, 'verifyCurrentFile'> {
   return {
     sourcePath: generationPath(root, sourceVersion, compression),
     sourceVersion,
@@ -156,7 +216,7 @@ function options(
 
 type TestMigrationOptions = ReturnType<typeof options> & {
   readonly signal?: AbortSignal
-  readonly verifyCurrentFile?: EnsureJsonlGenerationOptions['verifyCurrentFile']
+  readonly verifyCurrentFile?: PrepareJsonlMigrationOptions['verifyCurrentFile']
 }
 type TestGenerationOverrides = Parameters<typeof createJsonlGenerationTestRuntime>[0]
 
@@ -175,17 +235,24 @@ async function ensureWithOverrides(
         expectedPrefix,
       )
   )
-  return runtime.ensure({
+  const prepared = await runtime.prepare({
     ...request,
     verifyCurrentFile,
   })
+  const identity = await prepared.publish()
+  const bytes = await readFile(request.currentPath)
+  return {
+    status: 'migrated' as const,
+    fromVersion: request.sourceVersion,
+    toVersion: request.format.currentVersion,
+    path: request.currentPath,
+    sourcePath: request.sourcePath,
+    snapshot: { identity, bytes },
+  }
 }
 
 function ensureJsonlGenerationCurrent(request: TestMigrationOptions) {
-  return ensureJsonlGenerationCurrentProduction({
-    ...request,
-    verifyCurrentFile: request.verifyCurrentFile ?? verifier(),
-  })
+  return ensureWithOverrides(request, {})
 }
 
 async function encodeZstd(version: number, rows: readonly unknown[]): Promise<Buffer> {
@@ -207,7 +274,7 @@ async function decodeZstdJsonl(path: string): Promise<string> {
 }
 
 describe('JSONL immutable generation publication', () => {
-  it('does not return until verification and publication complete', async () => {
+  it('returns migrated events while publication is still waiting for verification', async () => {
     const root = await tempRoot()
     const request = options(root, 'none', streamingAdapter())
     const boundaryBase = { ...event0, data: { turn: 1, text: '' } }
@@ -223,7 +290,7 @@ describe('JSONL immutable generation publication', () => {
     const entered = Promise.withResolvers<undefined>()
     const release = Promise.withResolvers<undefined>()
 
-    const migration = ensureJsonlGenerationCurrent({
+    const prepared = await prepareJsonlMigration({
       ...request,
       verifyCurrentFile: async (path, compression, expectedId, expectedEventCount) => {
         entered.resolve(undefined)
@@ -231,11 +298,14 @@ describe('JSONL immutable generation publication', () => {
         return verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount)
       },
     })
+    expect(prepared.artifact.events).toEqual([boundaryEvent, largeEvent, finalEvent])
+    const publication = prepared.publish()
+    expect(prepared.publish()).toBe(publication)
     await entered.promise
     await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
 
     release.resolve(undefined)
-    await migration
+    await publication
     const [writtenHeader, ...writtenEvents] = (await readFile(request.currentPath, 'utf8')).trimEnd().split('\n')
     expect(JSON.parse(writtenHeader as string)).toEqual({ ...header(2), isSeeded: false })
     expect(writtenEvents.map(row => JSON.parse(row) as unknown)).toEqual([boundaryEvent, largeEvent, finalEvent])
@@ -252,15 +322,16 @@ describe('JSONL immutable generation publication', () => {
     const events = widths.map((_, seq) => ({ ...event0, seq }))
     await writeFile(request.sourcePath, line(header(0)) + events.map(line).join(''))
 
-    await ensureJsonlGenerationCurrent({
+    const prepared = await prepareJsonlMigration({
       ...request,
       verifyCurrentFile: byteVerifier,
     })
+    await prepared.publish()
 
     expect((await stat(request.currentPath)).size).toBeGreaterThan(8 * mib)
   })
 
-  it('retries migration when the source changes before publication', async () => {
+  it('fails publication without rerunning migration when the source changes', async () => {
     const root = await tempRoot()
     const base = streamingAdapter()
     const sourceStreams = vi.fn()
@@ -274,21 +345,18 @@ describe('JSONL immutable generation publication', () => {
     const source = line(header(0)) + line(event0)
     await writeFile(request.sourcePath, source)
 
-    let verifications = 0
-    await ensureJsonlGenerationCurrent({
+    const prepared = await prepareJsonlMigration({
       ...request,
       verifyCurrentFile: async (path, compression, expectedId, expectedEventCount) => {
         const verified = await verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount)
-        if (++verifications === 1) await writeFile(request.sourcePath, source + line(event1))
+        await writeFile(request.sourcePath, source + line(event1))
         return verified
       },
     })
 
-    expect(sourceStreams).toHaveBeenCalledTimes(2)
-    expect(verifications).toBe(3)
-    expect(await readFile(request.currentPath, 'utf8')).toBe(
-      line(header(2)) + line(event0) + line(event1),
-    )
+    await expect(prepared.publish()).rejects.toBeInstanceOf(JsonlGenerationSourceChangedError)
+    expect(sourceStreams).toHaveBeenCalledOnce()
+    await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
   })
 
   it('refuses malformed streaming inputs before publication', async () => {
@@ -296,23 +364,24 @@ describe('JSONL immutable generation publication', () => {
     const request = options(root, 'none', streamingAdapter())
 
     await writeFile(request.sourcePath, '')
-    await expect(ensureJsonlGenerationCurrent({ ...request, verifyCurrentFile: vi.fn() }))
+    await expect(prepareJsonlMigration({ ...request, verifyCurrentFile: vi.fn() }))
       .rejects.toThrow('empty or header-less')
 
     await writeFile(request.sourcePath, line(header(1)))
-    await expect(ensureJsonlGenerationCurrent({ ...request, verifyCurrentFile: vi.fn() }))
+    await expect(prepareJsonlMigration({ ...request, verifyCurrentFile: vi.fn() }))
       .rejects.toThrow(/filename identifies v0.*header identifies v1/)
 
     await writeFile(request.sourcePath, line(header(0)) + '{bad json}\n' + line(event1))
-    await expect(ensureJsonlGenerationCurrent({ ...request, verifyCurrentFile: vi.fn() }))
+    await expect(prepareJsonlMigration({ ...request, verifyCurrentFile: vi.fn() }))
       .rejects.toThrow('row 1 is not valid JSON')
 
     await writeFile(request.sourcePath, line(header(0)) + '{bad json}\n' + line(event0))
-    await ensureJsonlGenerationCurrent({
+    const dropped = await prepareJsonlMigration({
       ...request,
       verifyCurrentFile: verifier(),
     })
-    expect((await readFile(request.currentPath, 'utf8')).trimEnd().split('\n')).toHaveLength(1)
+    expect(dropped.artifact.events).toEqual([])
+    await dropped.publish()
 
   })
 
@@ -346,11 +415,52 @@ describe('JSONL immutable generation publication', () => {
       .rejects.toThrow('torn physical tail')
   })
 
+  it('keeps complete Assistant stream checks in current-generation verification', async () => {
+    const root = await tempRoot()
+    const path = generationPath(root, 2, 'none')
+    const verify = async (events: readonly SessionFormatEvent[]) => {
+      await writeFile(path, line(header(2)) + events.map(line).join(''))
+      return verifyJsonlCurrentGeneration(path, 'none', 'generation-test', events.length)
+    }
+
+    const valid = [
+      assistantLifecycle('assistant/message', assistantData()),
+      assistantLifecycle('assistant/message', assistantData({
+        interrupted: true,
+        stream: [{ type: 'text-chunks', time0: 3, index: 0, dt: [], texts: ['hello'] }],
+        usage: null,
+        replayState: null,
+      })),
+      assistantLifecycle('assistant/message', assistantData({
+        content: [], stream: [], usage: null, replayState: null,
+      })),
+      assistantLifecycle('assistant/attempt', {
+        turn: 1,
+        step: 1,
+        stream: [{ type: 'text-chunks', time0: 3, index: 0, dt: [1], texts: ['a', 'b'] }],
+      }),
+    ]
+    for (const events of valid) expect((await verify(events)).bytes).toBeGreaterThan(0)
+
+    await expect(verify(assistantLifecycle('assistant/attempt', {
+      turn: 1, step: 1, stream: [{ type: 'future' }],
+    }))).rejects.toThrow(/invalid embedded stream/)
+    await expect(verify(assistantLifecycle('assistant/message', assistantData({
+      content: [{ type: 'text', text: 'different' }],
+    })))).rejects.toThrow(/content disagrees/)
+    await expect(verify(assistantLifecycle('assistant/message', assistantData({
+      usage: { inputTokens: 9, outputTokens: 2 },
+    })))).rejects.toThrow(/usage disagrees/)
+    await expect(verify(assistantLifecycle('assistant/message', assistantData({
+      replayState: { response: { id: 'different' } },
+    })))).rejects.toThrow(/replay state disagrees/)
+  })
+
   it('accepts an identical publication winner and rejects different bytes', async () => {
     const identicalRoot = await tempRoot()
     const identical = options(identicalRoot, 'none', streamingAdapter())
     await writeFile(identical.sourcePath, line(header(0)) + line(event0))
-    await ensureJsonlGenerationCurrent({
+    const prepared = await prepareJsonlMigration({
       ...identical,
       verifyCurrentFile: async (path, compression, expectedId, expectedEventCount) => {
         const verified = await verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount)
@@ -358,16 +468,17 @@ describe('JSONL immutable generation publication', () => {
         return verified
       },
     })
-    expect((await stat(identical.currentPath, { bigint: true })).size).toBeGreaterThan(0n)
+    expect((await prepared.publish()).size).toBeGreaterThan(0n)
 
     const differentRoot = await tempRoot()
     const different = options(differentRoot, 'none', streamingAdapter())
     await writeFile(different.sourcePath, line(header(0)) + line(event0))
     await writeFile(different.currentPath, line({ ...header(2), isSeeded: false }) + line({ ...event0, time: 99 }))
-    await expect(ensureJsonlGenerationCurrent({
+    const conflicted = await prepareJsonlMigration({
       ...different,
       verifyCurrentFile: verifier(),
-    })).rejects.toBeInstanceOf(JsonlGenerationTargetConflictError)
+    })
+    await expect(conflicted.publish()).rejects.toBeInstanceOf(JsonlGenerationTargetConflictError)
 
     const uncheckedRoot = await tempRoot()
     const unchecked = options(uncheckedRoot, 'none', streamingAdapter())
@@ -376,17 +487,19 @@ describe('JSONL immutable generation publication', () => {
       unchecked.currentPath,
       line({ ...header(2), isSeeded: false }) + line({ ...event0, time: 99 }),
     )
-    await expect(ensureJsonlGenerationCurrent({
+    const uncheckedPublication = await prepareJsonlMigration({
       ...unchecked,
       verifyCurrentFile: byteVerifier,
-    })).rejects.toThrow(/target bytes differ from the migrated generation/)
+    })
+    await expect(uncheckedPublication.publish())
+      .rejects.toThrow(/target bytes differ from the migrated generation/)
   })
 
   it('handles empty, incomplete-record, and torn Zstandard migration sources', async () => {
     const emptyRoot = await tempRoot()
     const empty = options(emptyRoot, 'zstd', streamingAdapter())
     await writeFile(empty.sourcePath, Buffer.alloc(0))
-    await expect(ensureJsonlGenerationCurrent({ ...empty, verifyCurrentFile: vi.fn() }))
+    await expect(prepareJsonlMigration({ ...empty, verifyCurrentFile: vi.fn() }))
       .rejects.toThrow('empty or header-less Zstandard')
 
     const incompleteRoot = await tempRoot()
@@ -395,7 +508,7 @@ describe('JSONL immutable generation publication', () => {
       await compressZstdFrame(line(header(0))),
       await compressZstdFrame(JSON.stringify(event0)),
     ]))
-    await expect(ensureJsonlGenerationCurrent({ ...incomplete, verifyCurrentFile: vi.fn() }))
+    await expect(prepareJsonlMigration({ ...incomplete, verifyCurrentFile: vi.fn() }))
       .rejects.toThrow('complete frame contains a torn JSONL record')
 
     const tornRoot = await tempRoot()
@@ -405,11 +518,12 @@ describe('JSONL immutable generation publication', () => {
       await compressZstdFrame(line(header(0))),
       tornBody.subarray(0, -3),
     ]))
-    await ensureJsonlGenerationCurrent({
+    const recovered = await prepareJsonlMigration({
       ...torn,
       verifyCurrentFile: verifier(),
     })
-    expect((await decodeZstdJsonl(torn.currentPath)).trimEnd().split('\n')).toHaveLength(3)
+    expect(recovered.artifact.events).toEqual([event0, event1])
+    await recovered.publish()
 
     const emptyTailRoot = await tempRoot()
     const emptyTail = options(emptyTailRoot, 'zstd', streamingAdapter())
@@ -417,11 +531,12 @@ describe('JSONL immutable generation publication', () => {
       await compressZstdFrame(line(header(0))),
       tornBody.subarray(0, 8),
     ]))
-    await ensureJsonlGenerationCurrent({
+    const withoutTail = await prepareJsonlMigration({
       ...emptyTail,
       verifyCurrentFile: verifier(),
     })
-    expect((await decodeZstdJsonl(emptyTail.currentPath)).trimEnd().split('\n')).toHaveLength(1)
+    expect(withoutTail.artifact.events).toEqual([])
+    await withoutTail.publish()
   })
 
   it('checks migration and verification identities exactly', async () => {
@@ -441,13 +556,18 @@ describe('JSONL immutable generation publication', () => {
     const mismatchRoot = await tempRoot()
     const mismatch = options(mismatchRoot, 'none', streamingAdapter())
     await writeFile(mismatch.sourcePath, line(header(0)))
-    await expect(ensureJsonlGenerationCurrent({
+    const mismatched = await prepareJsonlMigration({
       ...mismatch,
       verifyCurrentFile: async (path, compression, expectedId, expectedEventCount) => ({
         ...await verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount),
         digest: 'different',
       }),
-    })).rejects.toThrow('changed during verification')
+    })
+    await expect(mismatched.publish()).rejects.toThrow('changed during verification')
+
+    const current = options(await tempRoot(), 'none', streamingAdapter(), 2)
+    await expect(prepareJsonlMigration({ ...current, verifyCurrentFile: vi.fn() }))
+      .rejects.toThrow('requires a historical source')
 
     const wrongRoot = await tempRoot()
     const wrongFormat = streamingAdapter()
@@ -464,7 +584,7 @@ describe('JSONL immutable generation publication', () => {
       }),
     })
     await writeFile(wrong.sourcePath, line(header(0)))
-    await expect(ensureJsonlGenerationCurrent({ ...wrong, verifyCurrentFile: vi.fn() }))
+    await expect(prepareJsonlMigration({ ...wrong, verifyCurrentFile: vi.fn() }))
       .rejects.toThrow('migration returned v0')
   })
 
@@ -478,12 +598,13 @@ describe('JSONL immutable generation publication', () => {
       sync: async () => {},
       close: async () => { throw new Error('close failed') },
     } as unknown as FileHandle
-    await expect(createJsonlGenerationTestRuntime({
+    const failedPreparation = await createJsonlGenerationTestRuntime({
       fs: { open: async () => failedHandle },
-    }).ensure({
+    }).prepare({
       ...failed,
       verifyCurrentFile: vi.fn(),
-    })).rejects.toBeInstanceOf(AggregateError)
+    })
+    await expect(failedPreparation.publish()).rejects.toBeInstanceOf(AggregateError)
   })
 
   it('propagates a streamed encoder failure through the Zstandard pipeline', async () => {
@@ -494,77 +615,26 @@ describe('JSONL immutable generation publication', () => {
     }))
     await writeFile(request.sourcePath, await encodeZstd(0, [event0]))
 
-    await expect(ensureJsonlGenerationCurrent({
+    const prepared = await prepareJsonlMigration({
       ...request,
       verifyCurrentFile: vi.fn(),
-    })).rejects.toBe(failure)
-    expect(await readdir(root)).toEqual(['session.jsonl.zstd'])
-  })
-
-  it('observes cancellation at the existing encode yield boundary', async () => {
-    const root = await tempRoot()
-    const controller = new AbortController()
-    const reason = new Error('cancelled during encoding')
-    const request = { ...options(root), signal: controller.signal }
-    const payload = 'x'.repeat(600 * 1024)
-    await writeFile(request.sourcePath, line(header(0)) + line({
-      ...event0, data: { turn: 1, payload },
-    }) + line({
-      ...event1, data: { turn: 1, reason: { kind: 'completed' }, payload },
-    }))
-    vi.spyOn(performance, 'now').mockReturnValue(0)
-    let yields = 0
-    vi.spyOn(scheduler, 'yield').mockImplementation(async () => {
-      yields += 1
-      if (yields === 2) controller.abort(reason)
     })
-
-    await expect(ensureJsonlGenerationCurrent({
-      ...request,
-      verifyCurrentFile: vi.fn(),
-    })).rejects.toBe(reason)
-    expect(yields).toBe(2)
-    expect(await readdir(root)).toEqual(['session.jsonl'])
-  })
-
-  it('forwards cancellation to staged verification', async () => {
-    const root = await tempRoot()
-    const controller = new AbortController()
-    const reason = new Error('cancelled during verification')
-    const request = { ...options(root), signal: controller.signal }
-    await writeFile(request.sourcePath, line(header(0)) + line(event0))
-    const verifyCurrentFile: EnsureJsonlGenerationOptions['verifyCurrentFile'] = async (
-      _path,
-      _compression,
-      _expectedId,
-      _expectedEventCount,
-      _expectedPrefix,
-      signal,
-    ) => {
-      expect(signal).toBe(controller.signal)
-      controller.abort(reason)
-      signal?.throwIfAborted()
-      throw new Error('unreachable')
-    }
-
-    await expect(ensureJsonlGenerationCurrent({
-      ...request,
-      verifyCurrentFile,
-    })).rejects.toBe(reason)
-    expect(await readdir(root)).toEqual(['session.jsonl'])
+    await expect(prepared.publish()).rejects.toBe(failure)
+    expect(await readdir(root)).toEqual(['session.jsonl.zstd'])
   })
 
-  it('publishes through the Windows no-overwrite path', async () => {
+  it('publishes a prepared stage through the Windows no-overwrite path', async () => {
     const winRoot = await tempRoot()
     const win = options(winRoot, 'none', streamingAdapter())
     await writeFile(win.sourcePath, line(header(0)))
-    await createJsonlGenerationTestRuntime({
+    const winPrepared = await createJsonlGenerationTestRuntime({
       platform: 'win32',
       publishNewWin32: rename,
-    }).ensure({
+    }).prepare({
       ...win,
       verifyCurrentFile: verifier(),
     })
+    await winPrepared.publish()
     expect(await readFile(win.currentPath, 'utf8')).toContain('"version":2')
   })
 
@@ -588,56 +658,6 @@ describe('JSONL immutable generation publication', () => {
     expect((await readdir(root)).sort()).toEqual(['session.jsonl', 'session.v2.jsonl'])
   })
 
-  it('takes the current fast path with one read and no format callback', async () => {
-    const root = await tempRoot()
-    const base = adapter()
-    const createRestore = vi.fn((value: Record<string, unknown>) => base.createRestore(value))
-    const encodeHeader = vi.fn((value: SessionFormatArtifact['header'], cut: number) =>
-      base.encodeHeader(value, cut))
-    const encodeEvent = vi.fn((value: SessionFormatArtifact['events'][number]) => base.encodeEvent(value))
-    const validateHistoricalHeader = vi.fn()
-    const request = {
-      ...options(root, 'none', { ...base, createRestore, encodeHeader, encodeEvent }, 2),
-      validateHistoricalHeader,
-    }
-    const contents = line({ ...header(2), isSeeded: false }) + line(event0)
-    await writeFile(request.sourcePath, contents)
-    const readStableFile = vi.fn(async (path: string, signal?: AbortSignal) =>
-      readFile(path, signal === undefined ? undefined : { signal }))
-
-    const result = await ensureWithOverrides(request, { fs: { readFile: readStableFile } })
-
-    expect(result).toMatchObject({ status: 'current', version: 2, path: request.sourcePath })
-    expect(readStableFile).toHaveBeenCalledOnce()
-    expect(createRestore).not.toHaveBeenCalled()
-    expect(encodeHeader).not.toHaveBeenCalled()
-    expect(encodeEvent).not.toHaveBeenCalled()
-    expect(validateHistoricalHeader).not.toHaveBeenCalled()
-    expect(await readFile(request.sourcePath, 'utf8')).toBe(contents)
-  })
-
-  it('bounds current snapshot retries under continuous revision churn', async () => {
-    const root = await tempRoot()
-    const request = options(root, 'none', adapter(), 2)
-    const contents = line(header(2)) + line(event0)
-    await writeFile(request.sourcePath, contents)
-    let revision = 0n
-    const statFile = vi.fn(async (path: string) => {
-      const value = await stat(path, { bigint: true })
-      revision += 1n
-      return { ...value, mtimeNs: value.mtimeNs + revision }
-    })
-    const readChangingFile = vi.fn(async () => Buffer.from(contents + line(event1)))
-
-    const result = await ensureWithOverrides(request, {
-      fs: { stat: statFile, readFile: readChangingFile },
-    })
-
-    expect(result.snapshot.bytes.toString('utf8')).toBe(contents)
-    expect(readChangingFile).toHaveBeenCalledTimes(2)
-    expect(statFile).toHaveBeenCalledTimes(3)
-  })
-
   it.each(['none', 'zstd'] as const)(
     'validates the selected %s historical header before invoking migration',
     async (compression) => {
@@ -700,24 +720,15 @@ describe('JSONL immutable generation publication', () => {
     expect(await readdir(root)).toEqual(['session.jsonl'])
   })
 
-  it('rejects malformed and future version discriminators before migration', async () => {
+  it('rejects a malformed version discriminator before migration', async () => {
     const root = await tempRoot()
     const malformed = options(join(root, 'malformed'))
-    const future = options(join(root, 'future'), 'none', adapter(), 3)
     await mkdir(join(root, 'malformed'))
-    await mkdir(join(root, 'future'))
     await writeFile(malformed.sourcePath, line(header(-1)))
-    await writeFile(future.sourcePath, line(header(3, 'future-id')))
 
     await expect(ensureJsonlGenerationCurrent(malformed)).rejects.toThrow(
       'header version is not a non-negative safe integer',
     )
-    await expect(ensureJsonlGenerationCurrent(future)).rejects.toMatchObject({
-      name: 'JsonlGenerationNewerVersionError',
-      storedVersion: 3,
-      currentVersion: 2,
-      storedId: 'future-id',
-    })
   })
 
   it.each([
@@ -976,7 +987,7 @@ describe('JSONL immutable generation publication', () => {
     }
   })
 
-  it('retries a bracketed physical read and a source changed before publication', async () => {
+  it('bounds a bracketed physical read and does not rerun migration after a publication race', async () => {
     const root = await tempRoot()
     const request = options(root)
     const first = Buffer.from(line(header(0)) + line(event0))
@@ -995,17 +1006,15 @@ describe('JSONL immutable generation publication', () => {
       if (phase === 'before-source-check' && attempt === 1) await writeFile(request.sourcePath, second)
     })
 
-    await ensureWithOverrides(
+    await expect(ensureWithOverrides(
       { ...request, format: { ...base, createRestore } },
       { fs: { stat: statFile }, barrier },
-    )
+    )).rejects.toBeInstanceOf(JsonlGenerationSourceChangedError)
 
     expect(stats).toBeGreaterThan(2)
-    expect(createRestore).toHaveBeenCalledTimes(2)
+    expect(createRestore).toHaveBeenCalledOnce()
     expect(await readFile(request.sourcePath)).toEqual(second)
-    expect(await readFile(request.currentPath, 'utf8')).toBe(
-      line(header(2)) + line(event0) + line(event1),
-    )
+    await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
     expect((await readdir(root)).every(name => !name.includes('.tmp'))).toBe(true)
   })
 
@@ -1020,7 +1029,7 @@ describe('JSONL immutable generation publication', () => {
       if (phase === 'before-source-check' && attempt === 1) await writeFile(request.sourcePath, second)
     }
 
-    await expect(ensureWithOverrides(request, {
+    const failure = await ensureWithOverrides(request, {
       barrier,
       fs: {
         rm: async (path: string) => {
@@ -1028,7 +1037,10 @@ describe('JSONL immutable generation publication', () => {
           await rm(path, { force: true })
         },
       },
-    })).rejects.toBe(cleanup)
+    }).then(() => undefined, (error: unknown) => error)
+    if (!(failure instanceof AggregateError)) throw new Error('expected source and cleanup failures')
+    expect(failure.errors[0]).toBeInstanceOf(JsonlGenerationSourceChangedError)
+    expect(failure.errors[1]).toBe(cleanup)
     expect(await readFile(request.sourcePath)).toEqual(second)
     await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
   })
@@ -1133,7 +1145,7 @@ describe('JSONL immutable generation publication', () => {
     const format = adapter()
     const request = {
       ...options(root, 'none', format),
-      verifyCurrentFile: async (...args: Parameters<EnsureJsonlGenerationOptions['verifyCurrentFile']>) => {
+      verifyCurrentFile: async (...args: Parameters<PrepareJsonlMigrationOptions['verifyCurrentFile']>) => {
         validations += 1
         if (validations === 2) throw 'non-error rejection'
         return verifier()(...args)
@@ -1175,7 +1187,26 @@ describe('JSONL immutable generation publication', () => {
     expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(2)) + line(event0))
   })
 
-  it('reports cancellation during committed reopen and leaves the target', async () => {
+  it('retains a committed generation when its post-publication stat fails', async () => {
+    const root = await tempRoot()
+    const request = options(root)
+    const statFailure = new Error('published target stat failed')
+    await writeFile(request.sourcePath, line(header(0)) + line(event0))
+    let targetStats = 0
+
+    await expect(ensureWithOverrides(request, {
+      fs: {
+        stat: async (path) => {
+          if (path === request.currentPath && ++targetStats === 1) throw statFailure
+          return stat(path, { bigint: true })
+        },
+      },
+    })).rejects.toBe(statFailure)
+    expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(2)) + line(event0))
+    expect((await readdir(root)).every(name => !name.includes('.tmp'))).toBe(true)
+  })
+
+  it('finishes a committed publication despite later caller cancellation', async () => {
     const root = await tempRoot()
     const controller = new AbortController()
     const reason = new Error('stop after publication')
@@ -1186,7 +1217,7 @@ describe('JSONL immutable generation publication', () => {
       barrier: (phase) => {
         if (phase === 'after-publication') controller.abort(reason)
       },
-    })).rejects.toBe(reason)
+    })).resolves.toMatchObject({ status: 'migrated', path: request.currentPath })
     expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(2)) + line(event0))
   })
 
@@ -1219,7 +1250,7 @@ describe('JSONL immutable generation publication', () => {
     })).rejects.toMatchObject({ code: 'ENOENT', path: request.currentPath })
   })
 
-  it('reopens a target after exclusive publication', async () => {
+  it('does not reopen a target after exclusive publication', async () => {
     const root = await tempRoot()
     const request = options(root)
     await writeFile(request.sourcePath, line(header(0)) + line(event0))
@@ -1233,7 +1264,7 @@ describe('JSONL immutable generation publication', () => {
         },
       },
     })).resolves.toMatchObject({ status: 'migrated', path: request.currentPath })
-    expect(reads).toContain(request.currentPath)
+    expect(reads).not.toContain(request.currentPath)
     expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(2)) + line(event0))
   })
 

+ 320 - 35
packages/session/session-persistence-jsonl/tests/jsonl.spec.ts

@@ -4,6 +4,7 @@ import { Context } from '@deepseek-ai/cordis'
 import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat, symlink } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { dirname, join, relative, resolve } from 'node:path'
+import { scheduler } from 'node:timers/promises'
 import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
 import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
@@ -18,6 +19,7 @@ import {
 } from '../../session-persistence/tests/contract.ts'
 import { runLiveWritePathContract } from '../../session-persistence/tests/live-write-contract.ts'
 import { LIVE_WRITE_BATCH_MAX_DELAY_MS, type JsonlSessionHandle } from '../src/storage.ts'
+import { JsonlGenerationSourceChangedError } from '../src/generation.ts'
 import SessionStore from '@deepseek-ai/dsh-session'
 
 const statRace = vi.hoisted(() => ({
@@ -43,6 +45,21 @@ const readTally = vi.hoisted(() => ({
   enabled: false,
 }))
 
+const readFailure = vi.hoisted(() => ({
+  path: undefined as string | undefined,
+  error: undefined as Error | undefined,
+}))
+
+const pausedRead = vi.hoisted(() => ({
+  path: undefined as string | undefined,
+  active: false,
+  entered: undefined as (() => void) | undefined,
+  resume: undefined as Promise<void> | undefined,
+  release: undefined as (() => void) | undefined,
+  done: undefined as Promise<void> | undefined,
+  finished: undefined as (() => void) | undefined,
+}))
+
 vi.mock('node:fs/promises', async (importOriginal) => {
   const actual = await importOriginal<typeof import('node:fs/promises')>()
   return {
@@ -57,10 +74,25 @@ vi.mock('node:fs/promises', async (importOriginal) => {
       return { ...identity, mtimeNs: identity.mtimeNs + 1n }
     }) as typeof actual.stat,
     readFile: (async (...args: Parameters<typeof actual.readFile>) => {
-      if (readTally.enabled && typeof args[0] === 'string') {
-        readTally.bySuffix.set(args[0], (readTally.bySuffix.get(args[0]) ?? 0) + 1)
+      const path = typeof args[0] === 'string' ? args[0] : undefined
+      if (path === readFailure.path && readFailure.error !== undefined) throw readFailure.error
+      if (readTally.enabled && path !== undefined) {
+        readTally.bySuffix.set(path, (readTally.bySuffix.get(path) ?? 0) + 1)
+      }
+      if (path !== pausedRead.path || pausedRead.resume === undefined) {
+        return actual.readFile(...args)
+      }
+      const resume = pausedRead.resume
+      const finished = pausedRead.finished
+      pausedRead.active = true
+      pausedRead.entered?.()
+      await resume
+      try {
+        return await actual.readFile(...args)
+      } finally {
+        pausedRead.active = false
+        finished?.()
       }
-      return actual.readFile(...args)
     }) as typeof actual.readFile,
     readdir: (async (...args: Parameters<typeof actual.readdir>) => {
       if (String(args[0]) === readdirFailure.path && readdirFailure.error !== undefined) {
@@ -107,6 +139,27 @@ async function freshRoot(): Promise<string> {
   return dir
 }
 
+function pausePhysicalRead(path: string): {
+  readonly entered: Promise<void>
+  readonly finished: Promise<void>
+  release(): void
+} {
+  const entered = Promise.withResolvers<undefined>()
+  const resume = Promise.withResolvers<undefined>()
+  const finished = Promise.withResolvers<undefined>()
+  pausedRead.path = path
+  pausedRead.entered = () => { entered.resolve(undefined) }
+  pausedRead.resume = resume.promise
+  pausedRead.release = () => { resume.resolve(undefined) }
+  pausedRead.done = finished.promise
+  pausedRead.finished = () => { finished.resolve(undefined) }
+  return {
+    entered: entered.promise,
+    finished: finished.promise,
+    release: () => { resume.resolve(undefined) },
+  }
+}
+
 function rawLogPath(root: string, cwd: string | undefined, id: SessionId): string {
   return logPath(root, cwd, id, 'none')
 }
@@ -178,7 +231,7 @@ async function writeLog(persistence: SessionPersistence, m: SessionHeader, event
 async function readAll(persistence: SessionPersistence, id: SessionId): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> {
   const handle = await persistence.open(id, 'read')
   try {
-    return { meta: handle.header, events: await handle.read() }
+    return { meta: handle.header, events: (await handle.read()).events }
   } finally {
     await handle.close()
   }
@@ -200,6 +253,18 @@ afterEach(async () => {
   statRace.mode = 'settle'
   readTally.bySuffix.clear()
   readTally.enabled = false
+  const pausedReadDone = pausedRead.active ? pausedRead.done : undefined
+  readFailure.path = undefined
+  readFailure.error = undefined
+  pausedRead.release?.()
+  await pausedReadDone
+  pausedRead.path = undefined
+  pausedRead.active = false
+  pausedRead.entered = undefined
+  pausedRead.resume = undefined
+  pausedRead.release = undefined
+  pausedRead.done = undefined
+  pausedRead.finished = undefined
   statFailure.path = undefined
   statFailure.error = undefined
   readdirFailure.path = undefined
@@ -451,6 +516,17 @@ describe('JsonlSessionPersistence: stored-format refusals', () => {
     expect(await ctx.sessionPersistence.list()).toEqual([])
   })
 
+  it('classifies an unparsable future generation header as corruption', async () => {
+    const id = SessionId('future-malformed')
+    const path = generationLogPath(root, '/work', id, 42, 'none')
+    await mkdir(dirname(path), { recursive: true })
+    await writeFile(path, '{not-json}\n')
+
+    await expect(ctx.sessionPersistence.open(id, 'read')).rejects.toMatchObject({
+      name: 'SessionPersistenceCorruptionError',
+    })
+  })
+
   it('refuses a well-shaped newer-version header at read open with the upgrade direction', async () => {
     // A header that satisfies the current shape but carries a future version:
     // stat can parse it, and the open still refuses before handing out a
@@ -524,7 +600,14 @@ describe('JsonlSessionPersistence: stored-format refusals', () => {
     const handle = await ctx.sessionPersistence.open(m.id, 'read', { signal: new AbortController().signal })
     try {
       expect(handle.header).toMatchObject({ id: m.id, cwd: '/work' })
-      expect(await handle.read()).toEqual(oneTurnLog())
+      const read = await handle.read()
+      expect(read.eventState).toBe('shared-frozen')
+      expect(read.events).toEqual(oneTurnLog())
+      expect(read.events.every(event => Object.isFrozen(event) && Object.isFrozen(event.data))).toBe(true)
+      const reread = await handle.read()
+      expect(reread.events).not.toBe(read.events)
+      expect(reread.events[0]).toBe(read.events[0])
+      expect((await handle.read(read.events.length)).eventState).toBe('shared-frozen')
     } finally {
       await handle.close()
     }
@@ -597,7 +680,7 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
     await expect(stat(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
   })
 
-  it('publishes v2 beside an unchanged v0 source before returning a read handle', async () => {
+  it('serves a migrated v0 read without publishing at the service durability barrier', async () => {
     const header = meta('released-v0-read', '/work')
     const sourcePath = historicalLogPath(root, header.cwd, header.id)
     const currentPath = rawLogPath(root, header.cwd, header.id)
@@ -607,18 +690,147 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
     await mkdir(dirname(sourcePath), { recursive: true })
     await writeFile(sourcePath, source)
 
-    await expect(readAll(ctx.sessionPersistence, header.id)).resolves.toEqual({
+    const restored = await readAll(ctx.sessionPersistence, header.id)
+    expect(restored).toEqual({
       meta: { ...header, delegationDepth: 0 },
       events: oneTurnLog(),
     })
+    const userMessage = restored.events.find(event => event.type === 'user/message')
+    expect(userMessage).toBeDefined()
+    expect(Object.isFrozen(userMessage?.data)).toBe(true)
     expect(await readFile(sourcePath)).toEqual(source)
-    const current = (await readFile(currentPath, 'utf8')).trimEnd().split('\n')
-    expect(JSON.parse(current[0] as string)).toMatchObject({
-      id: header.id,
-      version: SESSION_FORMAT_VERSION,
-    })
+    await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
     expect((await readdir(dirname(sourcePath))).filter(name => name.startsWith('session')).sort())
-      .toEqual(['session.jsonl', 'session.v2.jsonl'])
+      .toEqual(['session.jsonl'])
+  })
+
+  it('resolves absent, current, and historical current-generation paths', async () => {
+    const persistence = ctx.sessionPersistence as JsonlSessionPersistence
+    expect(await persistence.resolveCurrentLog(SessionId('missing-generation'))).toBeUndefined()
+
+    const current = meta('resolved-current', '/work')
+    const currentHandle = await ctx.sessionPersistence.create(current)
+    await currentHandle.flush()
+    await currentHandle.close()
+    await expect(persistence.resolveCurrentLog(current.id)).resolves.toBe(rawLogPath(root, current.cwd, current.id))
+
+    const historical = meta('resolved-historical', '/work')
+    const sourcePath = historicalLogPath(root, historical.cwd, historical.id)
+    await mkdir(dirname(sourcePath), { recursive: true })
+    await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(historical))}\n`)
+    await expect(persistence.resolveCurrentLog(historical.id)).resolves.toBeUndefined()
+
+    const future = meta('resolved-future', '/work')
+    const futurePath = generationLogPath(root, future.cwd, future.id, SESSION_FORMAT_VERSION + 1, 'none')
+    await mkdir(dirname(futurePath), { recursive: true })
+    await writeFile(futurePath, `${JSON.stringify({ ...toHeaderLine(future), version: SESSION_FORMAT_VERSION + 1 })}\n`)
+    await expect(persistence.resolveCurrentLog(future.id)).rejects.toMatchObject({
+      name: 'SessionFormatUnsupportedError',
+    })
+  })
+
+  it('singleflights concurrent historical reads and keeps service flush read-only', async () => {
+    const header = meta('released-v0-source-drift', '/work')
+    const sourcePath = historicalLogPath(root, header.cwd, header.id)
+    await mkdir(dirname(sourcePath), { recursive: true })
+    await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
+    readTally.enabled = true
+
+    const [first, second] = await Promise.all([
+      ctx.sessionPersistence.open(header.id, 'read'),
+      ctx.sessionPersistence.open(header.id, 'read'),
+    ])
+    expect((await first.read()).events).toEqual([])
+    expect((await second.read()).events).toEqual([])
+    expect(readTally.bySuffix.get(sourcePath)).toBe(1)
+    await appendFile(sourcePath, '\n')
+
+    await expect(ctx.sessionPersistence.flush()).resolves.toBeUndefined()
+    await expect(stat(rawLogPath(root, header.cwd, header.id))).rejects.toMatchObject({ code: 'ENOENT' })
+    await Promise.all([first.close(), second.close()])
+    await ctx.fiber.dispose()
+    ctx = new Context()
+  })
+
+  it('does not join an in-flight historical preparation for an older source revision', async () => {
+    const header = meta('released-v0-revision-singleflight', '/work')
+    const sourcePath = historicalLogPath(root, header.cwd, header.id)
+    await mkdir(dirname(sourcePath), { recursive: true })
+    await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
+    const pause = pausePhysicalRead(sourcePath)
+    readTally.enabled = true
+
+    const firstOpening = ctx.sessionPersistence.open(header.id, 'read')
+    await pause.entered
+    await appendFile(sourcePath, `${eventLines(releasedV1OneTurnLog())}\n`)
+    const secondOpening = ctx.sessionPersistence.open(header.id, 'read')
+    let tallyFailure: unknown
+    try {
+      await vi.waitFor(() => { expect(readTally.bySuffix.get(sourcePath)).toBe(2) })
+    } catch (error: unknown) {
+      tallyFailure = error
+    } finally {
+      pause.release()
+    }
+
+    const [first, second] = await Promise.all([firstOpening, secondOpening])
+    try {
+      if (tallyFailure !== undefined) throw tallyFailure
+      expect((await first.read()).events).toEqual(oneTurnLog())
+      expect((await second.read()).events).toEqual(oneTurnLog())
+    } finally {
+      await Promise.all([first.close(), second.close()])
+    }
+  })
+
+  it('lets one historical-open caller abort without cancelling another waiter', async () => {
+    const header = meta('released-v0-shared-cancellation', '/work')
+    const sourcePath = historicalLogPath(root, header.cwd, header.id)
+    await mkdir(dirname(sourcePath), { recursive: true })
+    await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
+    const pause = pausePhysicalRead(sourcePath)
+    readTally.enabled = true
+    const controller = new AbortController()
+    const reason = new Error('first historical waiter cancelled')
+
+    const first = ctx.sessionPersistence.open(header.id, 'read', { signal: controller.signal })
+    const second = ctx.sessionPersistence.open(header.id, 'read')
+    await pause.entered
+    await scheduler.yield()
+    controller.abort(reason)
+    await expect(first).rejects.toBe(reason)
+    pause.release()
+    const handle = await second
+    expect((await handle.read()).events).toEqual([])
+    expect(readTally.bySuffix.get(sourcePath)).toBe(1)
+    await handle.close()
+  })
+
+  it('cancels shared historical preparation after its last waiter leaves', async () => {
+    const header = meta('released-v0-last-waiter-cancellation', '/work')
+    const sourcePath = historicalLogPath(root, header.cwd, header.id)
+    await mkdir(dirname(sourcePath), { recursive: true })
+    await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
+    const pause = pausePhysicalRead(sourcePath)
+    readTally.enabled = true
+    const controller = new AbortController()
+    const reason = 'last historical waiter cancelled'
+
+    const opening = ctx.sessionPersistence.open(header.id, 'read', { signal: controller.signal })
+    await pause.entered
+    controller.abort(reason)
+    await expect(opening).rejects.toMatchObject({
+      message: 'session migration preparation aborted',
+      cause: reason,
+    })
+    pause.release()
+    await pause.finished
+    await scheduler.yield()
+
+    const retried = await ctx.sessionPersistence.open(header.id, 'read')
+    expect((await retried.read()).events).toEqual([])
+    expect(readTally.bySuffix.get(sourcePath)).toBe(2)
+    await retried.close()
   })
 
   it('migrates released-v0 retry, repeated-compaction, provenance, and late-title shapes', async () => {
@@ -648,16 +860,15 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
     expect(titleBlock).toMatchObject({ type: 'text' })
     if (titleBlock?.type !== 'text') throw new Error('fixture title request lacks its text block')
     expect(titleBlock.text).toContain('{"seq":21,"text":"late"}')
-    const currentRows = (await readFile(currentPath, 'utf8')).trimEnd().split('\n')
-      .map(line => JSON.parse(line) as Record<string, unknown>)
-    expect(currentRows.find(row => row['type'] === 'user/message'
-      && (row['data'] as { source?: { plugin?: string } }).source?.plugin === 'compact'))
+    expect(restored.events.find(event => event.type === 'user/message'
+      && (event.data as { source?: { plugin?: string } }).source?.plugin === 'compact'))
       .toMatchObject({
         seq: 14,
         sourceEventSeqs: [12, 13, 11, 2, 3, 4],
         surfaceOp: { op: 'replace', start: 11, end: 4 },
       })
     expect(await readFile(sourcePath)).toEqual(source)
+    await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
   })
 
   it('publishes v2 beside an unchanged physical v1 source with packed chunk rows', async () => {
@@ -677,10 +888,9 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
       .toMatchObject({ data: { message: { content: [{ type: 'text', text: 'hello' }] } } })
 
     expect(await readFile(sourcePath)).toEqual(source)
-    expect(JSON.parse((await readFile(currentPath, 'utf8')).split('\n')[0] as string))
-      .toMatchObject({ id: header.id, version: SESSION_FORMAT_VERSION })
+    await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
     expect((await readdir(dirname(sourcePath))).filter(name => name.startsWith('session')).sort())
-      .toEqual(['session.v1.jsonl', 'session.v2.jsonl'])
+      .toEqual(['session.v1.jsonl'])
   })
 
   it('selects v1 from a v0/v1 directory, then v2 from the retained three-generation set', async () => {
@@ -695,8 +905,12 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
 
     const migrated = await readAll(ctx.sessionPersistence, header.id)
     expect(migrated.events.map(event => event.type)).toContain('assistant/message')
+    const writer = await ctx.sessionPersistence.open(header.id, 'write')
+    await writer.close()
     expect((await readdir(directory)).filter(name => name.startsWith('session')).sort())
-      .toEqual(['session.jsonl', 'session.v1.jsonl', 'session.v2.jsonl'])
+      .toEqual(process.platform === 'win32'
+        ? ['session.jsonl', 'session.v1.jsonl', 'session.v2.jsonl']
+        : ['session.jsonl', 'session.lock', 'session.v1.jsonl', 'session.v2.jsonl'])
 
     await writeFile(v0Path, 'corrupt lower v0\n')
     await writeFile(v1Path, 'corrupt lower v1\n')
@@ -704,18 +918,17 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
     expect(await readFile(v2Path, 'utf8')).toContain('"version":2')
   })
 
-  it('uses the same migration path for a handle storage resolution', async () => {
+  it('does not publish a historical generation through handle storage resolution', async () => {
     const header = meta('released-v0-handle-read', '/work')
     const sourcePath = historicalLogPath(root, header.cwd, header.id)
     const currentPath = rawLogPath(root, header.cwd, header.id)
     await mkdir(dirname(sourcePath), { recursive: true })
     await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
-    const storage = ctx.sessionPersistence as unknown as {
-      resolveLog(id: SessionId, signal?: AbortSignal): Promise<string | undefined>
-    }
+    const persistence = ctx.sessionPersistence as JsonlSessionPersistence
 
-    await expect(storage.resolveLog(header.id, new AbortController().signal)).resolves.toBe(currentPath)
+    await expect(persistence.resolveCurrentLog(header.id, new AbortController().signal)).resolves.toBeUndefined()
     expect(await readFile(sourcePath, 'utf8')).toBe(`${JSON.stringify(releasedV0Header(header))}\n`)
+    await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
   })
 
   it('opens the migrated successor for append while retaining the historical source', async () => {
@@ -740,6 +953,68 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
     ])
   })
 
+  it('switches an existing prepared read handle to the published append tail', async () => {
+    const header = meta('released-v0-read-handoff', '/work')
+    const sourcePath = historicalLogPath(root, header.cwd, header.id)
+    await mkdir(dirname(sourcePath), { recursive: true })
+    await writeFile(
+      sourcePath,
+      `${JSON.stringify(releasedV0Header(header))}\n${eventLines(releasedV1OneTurnLog())}\n`,
+    )
+    const reader = await ctx.sessionPersistence.open(header.id, 'read')
+    const suffix: SessionEvent[] = [
+      { type: 'turn/start', seq: SessionSeq(6), time: 9, data: { turn: 2 } },
+      { type: 'turn/end', seq: SessionSeq(7), time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
+    ]
+    try {
+      expect((await reader.read()).events).toEqual(oneTurnLog())
+      await appendBatch(ctx.sessionPersistence, header.id, suffix)
+      expect((await reader.read()).events).toEqual([...oneTurnLog(), ...suffix])
+    } finally {
+      await reader.close()
+    }
+  })
+
+  it('fails a stale prepared publication once and re-prepares on the next write open', async () => {
+    const header = meta('released-v0-write-source-drift', '/work')
+    const sourcePath = historicalLogPath(root, header.cwd, header.id)
+    const currentPath = rawLogPath(root, header.cwd, header.id)
+    const source = `${JSON.stringify(releasedV0Header(header))}\n`
+    await mkdir(dirname(sourcePath), { recursive: true })
+    await writeFile(sourcePath, source)
+    await readAll(ctx.sessionPersistence, header.id)
+    vi.spyOn(scheduler, 'yield').mockImplementationOnce(async () => {
+      await appendFile(sourcePath, '\n')
+    })
+
+    await expect(ctx.sessionPersistence.open(header.id, 'write'))
+      .rejects.toBeInstanceOf(JsonlGenerationSourceChangedError)
+    await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
+
+    const writer = await ctx.sessionPersistence.open(header.id, 'write')
+    await writer.close()
+    expect(await readFile(sourcePath, 'utf8')).toBe(`${source}\n`)
+    expect(await readFile(currentPath, 'utf8')).toContain('"version":2')
+  })
+
+  it('finishes publication before rejecting a write open cancelled during publication', async () => {
+    const header = meta('released-v0-publication-cancellation', '/work')
+    const sourcePath = historicalLogPath(root, header.cwd, header.id)
+    const currentPath = rawLogPath(root, header.cwd, header.id)
+    await mkdir(dirname(sourcePath), { recursive: true })
+    await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
+    await readAll(ctx.sessionPersistence, header.id)
+    const controller = new AbortController()
+    const reason = new Error('write open cancelled during publication')
+    vi.spyOn(scheduler, 'yield').mockImplementationOnce(async () => { controller.abort(reason) })
+
+    await expect(ctx.sessionPersistence.open(header.id, 'write', { signal: controller.signal }))
+      .rejects.toBe(reason)
+    expect(await readFile(currentPath, 'utf8')).toContain('"version":2')
+    const writer = await ctx.sessionPersistence.open(header.id, 'write')
+    await writer.close()
+  })
+
   it('treats a historical generation as an existing id at create', async () => {
     const header = meta('released-v0-collision', '/work')
     const sourcePath = historicalLogPath(root, header.cwd, header.id)
@@ -822,6 +1097,12 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
     await expect(ctx.sessionPersistence.open(header.id, 'read')).rejects.toMatchObject({ code: 'EACCES' })
     statFailure.error = new DOMException('source read aborted', 'AbortError')
     await expect(ctx.sessionPersistence.open(header.id, 'read')).rejects.toMatchObject({ name: 'AbortError' })
+    statFailure.error = undefined
+    readFailure.path = sourcePath
+    readFailure.error = new DOMException('source read failed', 'InvalidStateError')
+    await expect(ctx.sessionPersistence.open(header.id, 'read')).rejects.toMatchObject({
+      name: 'SessionPersistenceCorruptionError',
+    })
   })
 
   it('selects the highest opposite-encoding generation for its refusal', async () => {
@@ -930,6 +1211,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
   it('lazy materialization: create() writes no file until the first append', async () => {
     const m = meta('lazy', '/work')
     const handle = await ctx.sessionPersistence.create(m)
+    expect((await handle.read()).eventState).toBe('detached')
     // create() materializes no file before the first append — while the
     // created session is already visible to this process.
     const dir = sessionDir(root, '/work', m.id)
@@ -951,7 +1233,10 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
     await handle.close()
 
     expect(await readFile(rawLogPath(root, '/work', m.id), 'utf8')).toBe(`${JSON.stringify(toHeaderLine(m))}\n`)
-    await expect(readAll(ctx.sessionPersistence, m.id)).resolves.toMatchObject({ events: [] })
+    const reader = await ctx.sessionPersistence.open(m.id, 'read')
+    const read = await reader.read()
+    expect(read).toEqual({ eventState: 'shared-frozen', events: [] })
+    await reader.close()
   })
 
   it('close drains a routed event that arrives while it waits for an in-flight append', async () => {
@@ -1062,7 +1347,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
     // ...and the immediate write-open (resume) reuses the parsed log through
     // the revision guard instead of re-reading the file.
     const writer = await ctx.sessionPersistence.open(m.id, 'write')
-    expect((await writer.read()).length).toBe(oneTurnLog().length)
+    expect((await writer.read()).events.length).toBe(oneTurnLog().length)
     expect(readTally.bySuffix.get(path)).toBe(1)
 
     // A local append invalidates the memo: the next cold read re-parses and
@@ -1122,7 +1407,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
       const internals = ctx.sessionPersistence as unknown as { coldLogMemo: Map<SessionId, unknown> }
       internals.coldLogMemo.clear()
       statRace.path = rawLogPath(root, '/work', m.id)
-      expect(await handle.read()).toEqual(oneTurnLog())
+      expect((await handle.read()).events).toEqual(oneTurnLog())
       // The memo probe, the initial identity, the mismatching post-read stat
       // (reused as the retry's pre-read identity), and the retry's matching
       // post-read stat.
@@ -1146,7 +1431,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
       // serves the retry's pre-read committed prefix — here the whole log.
       // Four stats: the memo probe, the initial identity, and one mismatching
       // post-read stat per bounded attempt.
-      expect(await handle.read()).toEqual(oneTurnLog())
+      expect((await handle.read()).events).toEqual(oneTurnLog())
       expect(statRace.reads).toBe(4)
     } finally {
       statRace.path = undefined
@@ -1300,7 +1585,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
 
       // The retry now succeeds with NO seq gap — the log is contiguous 0..7.
       await handle.append(turn2)
-      expect((await handle.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
+      expect((await handle.read()).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
     } finally {
       await handle.close()
     }
@@ -1412,7 +1697,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
       ], { signal })).rejects.toBe(reason)
       await expect(handle.flush({ signal })).rejects.toBe(reason)
       // The aborted mutations left the log untouched.
-      expect((await handle.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5])
+      expect((await handle.read()).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5])
     } finally {
       await handle.close()
     }
@@ -1473,7 +1758,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
     await handle.append([])
     await expect(stat(rawLogPath(root, '/work', m.id))).rejects.toThrow()
     await handle.append(oneTurnLog())
-    expect((await handle.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5])
+    expect((await handle.read()).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5])
     await handle.close()
   })
 
@@ -1498,7 +1783,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
     const m = meta('erased-pending')
     const creator = await ctx.sessionPersistence.create(m)
     const reader = await ctx.sessionPersistence.open(m.id, 'read')
-    expect(await reader.read()).toEqual([])
+    expect((await reader.read()).events).toEqual([])
     // The creator closes without ever appending: the session never existed.
     await creator.close()
     await expect(reader.read()).rejects.toThrow(/not found/)
@@ -1510,7 +1795,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
     await writeLog(ctx.sessionPersistence, m, oneTurnLog())
     const reader = await ctx.sessionPersistence.open(m.id, 'read')
     try {
-      expect(await reader.read()).toHaveLength(6)
+      expect((await reader.read()).events).toHaveLength(6)
       // Committed events are never rewritten; a shorter file is damage, not a
       // legal state, and a handle must not silently backtrack.
       await writeFile(rawLogPath(root, '/work', m.id), [

+ 1 - 1
packages/session/session-persistence-jsonl/tests/lease.spec.ts

@@ -181,7 +181,7 @@ describe('cross-process write lock', () => {
     await pendingWinner.close()
     // Reads never touch the lock.
     const reader = await second.open(SessionId('excluded'), 'read')
-    expect((await reader.read()).map(event => event.seq)).toEqual([0, 1])
+    expect((await reader.read()).events.map(event => event.seq)).toEqual([0, 1])
     await reader.close()
 
     await holder.close()

+ 2 - 2
packages/session/session-persistence-jsonl/tests/lease.two-process.e2e.ts

@@ -51,7 +51,7 @@ describe('two-process write lock (built lib)', () => {
       await expect(mine.open(SessionId(SESSION), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError)
       // Reads are unaffected across processes.
       const reader = await mine.open(SessionId(SESSION), 'read')
-      expect((await reader.read()).map(event => event.seq)).toEqual([0, 1])
+      expect((await reader.read()).events.map(event => event.seq)).toEqual([0, 1])
       await reader.close()
 
       // Crash the holder: no release runs, but the kernel drops the lock with
@@ -60,7 +60,7 @@ describe('two-process write lock (built lib)', () => {
       await exited
       const taken = await mine.open(SessionId(SESSION), 'write')
       await taken.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }])
-      expect((await taken.read()).map(event => event.seq)).toEqual([0, 1, 2])
+      expect((await taken.read()).events.map(event => event.seq)).toEqual([0, 1, 2])
       await taken.close()
     } finally {
       if (holder.exitCode === null) holder.kill('SIGKILL')

+ 3 - 1
packages/session/session-persistence-jsonl/tests/migration-verifier.spec.ts

@@ -48,10 +48,12 @@ afterEach(() => {
 
 describe('migration verifier Worker lifecycle', () => {
   it('resolves only after terminating a successful Worker', async () => {
-    const verification = verifyCurrentGenerationInWorker('/stage', 'none', 'session', 2)
+    const expectedPrefix = { bytes: 3, digest: 'a'.repeat(64) }
+    const verification = verifyCurrentGenerationInWorker('/stage', 'none', 'session', 2, expectedPrefix)
     const instance = worker()
     expect(instance.options.workerData).toEqual({
       path: '/stage', compression: 'none', expectedId: 'session', expectedEventCount: 2,
+      expectedPrefix,
     })
     instance.emit('message', { ok: true, result })
 

+ 4 - 8
packages/session/session-persistence-jsonl/tests/zstd.spec.ts

@@ -5,7 +5,7 @@ import type { FileHandle } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { performance } from 'node:perf_hooks'
-import { SESSION_FORMAT_VERSION, SessionSeq, SessionId } from '@deepseek-ai/dsh-session'
+import { SessionSeq, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
 import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
 import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
@@ -68,7 +68,7 @@ async function writeLog(persistence: SessionPersistence, m: SessionHeader, event
 async function readAll(persistence: SessionPersistence, id: SessionId): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> {
   const handle = await persistence.open(id, 'read')
   try {
-    return { meta: handle.header, events: await handle.read() }
+    return { meta: handle.header, events: (await handle.read()).events }
   } finally {
     await handle.close()
   }
@@ -411,7 +411,7 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => {
     expect((await readAll(ctx.sessionPersistence, header.id)).events).toEqual(oneTurnLog())
   })
 
-  it('publishes v2 beside an unchanged compressed v0 source before returning a read handle', async () => {
+  it('serves a migrated compressed v0 read without publishing a successor', async () => {
     const root = await freshRoot()
     const ctx = await mount(root)
     const header = meta('zstd-v0-read', '/work')
@@ -429,11 +429,7 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => {
       events: oneTurnLog(),
     })
     expect(await readFile(sourcePath)).toEqual(source)
-    const current = (await decodeCompleteFrames(await readFile(currentPath))).toString().split('\n')
-    expect(JSON.parse(current[0] as string)).toMatchObject({
-      id: header.id,
-      version: SESSION_FORMAT_VERSION,
-    })
+    await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
   })
 
 

+ 2 - 2
packages/session/session-persistence/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md
-README.md: 12b335a7e0e266d4c8d0ad26a81a1440a9b47160
-README.zh.md: 20c31c4df59c5928ed28d1115ec70f1433129552
+README.md: 4b00eaf9f5692011a223c60449cb8e064d78d42e
+README.zh.md: 54907864585245502b9b0fe0e030ac151af80231

+ 1 - 1
packages/session/session-persistence/README.md

@@ -46,7 +46,7 @@ await ctx.sessionPersistence.flush()                           // backend-wide d
 
 Service-level `flush()` drains every active write handle's routed events and materializes its session, exactly as each handle's own `flush` would; failures aggregate per session as an `AggregateError` without abandoning the sweep, and a handle closed mid-sweep counts as flushed because close itself drains durably.
 
-Every log read and write flows through the returned `SessionHandle`; there are no id-addressed append or load methods. `handle.read(offset?, length?)` returns validated contiguous prefix slices — never a torn tail, and repeated reads on one handle never observe an older state than a prior read; a write handle reads its own successful appends. `handle.append(events)` appends a contiguous batch whose first `seq` equals the stored next-seq; persistence is best-effort on resolution — the batch is accepted, ordered, and visible to reads on this backend instance, and only a resolved `flush` promises it survives a crash (the shipped JSONL backend happens to persist each batch immediately). `handle.flush()` is the durability barrier and also materializes an empty created session so it becomes durably listable. `handle.close()` is idempotent and uncancellable: a read handle frees local resources, a write handle completes pending durability and releases write ownership. Once an `append` or `flush` resolves, reads started afterwards on the same backend instance — on any handle, or through `stat`/`list` — observe at least that prefix.
+Every log read and write flows through the returned `SessionHandle`; there are no id-addressed append or load methods. `handle.read(offset?, length?)` returns `{ eventState, events }`: the outer slice belongs to the caller, while `eventState` distinguishes an exclusively `detached` event graph from a `shared-frozen` graph that may also reside in a backend cache. The producer establishes this state and slices preserve it even when empty. Both states are safe to adopt without copying; a consumer that needs mutable events clones them first. Reads never include a torn tail, repeated reads on one handle never observe an older state than a prior read, and a write handle reads its own successful appends. `handle.append(events)` appends a contiguous batch whose first `seq` equals the stored next-seq; persistence is best-effort on resolution — the batch is accepted, ordered, and visible to reads on this backend instance, and only a resolved `flush` promises it survives a crash (the shipped JSONL backend happens to persist each batch immediately). `handle.flush()` is the durability barrier and also materializes an empty created session so it becomes durably listable. `handle.close()` is idempotent and uncancellable: a read handle frees local resources; a write handle completes pending durability and releases write ownership. Once an `append` or `flush` resolves, reads started afterwards on the same backend instance — on any handle, or through `stat`/`list` — observe at least that prefix.
 
 ### Ownership and visibility
 

+ 1 - 1
packages/session/session-persistence/README.zh.md

@@ -46,7 +46,7 @@ await ctx.sessionPersistence.flush()                           // backend-wide d
 
 服务级 `flush()` 排空每个活跃写句柄已路由的事件并把其会话实体化,效果与各句柄自己的 `flush` 完全相同;失败按会话聚合为一个 `AggregateError` 而不中途放弃清扫,清扫途中被关闭的句柄视同已 flush,因为 close 本身会持久排空。
 
-每一次日志读写都流经返回的 `SessionHandle`;不存在按 id 寻址的 append 或 load 方法。`handle.read(offset?, length?)` 返回经过验证的连续前缀切片——绝不返回撕裂尾部,且同一句柄上的重复读取绝不会观察到比先前读取更旧的状态;写句柄能读到自己成功的 append。`handle.append(events)` 追加一个连续批次,其第一个 `seq` 等于已存储 next-seq;完成时的持久化是尽力而为的——批次被接受、有序,并对同一后端实例上的读取可见,只有完成的 `flush` 才承诺它在崩溃后依然存在(交付的 JSONL 后端恰好会立即持久化每个批次)。`handle.flush()` 是持久性屏障,同时把空的已创建会话实体化,使其可被持久列出。`handle.close()` 幂等且不可取消:读句柄释放本地资源,写句柄完成待处理的持久化并释放写所有权。一旦某次 `append` 或 `flush` 完成,其后在同一后端实例上开始的读取——无论经由任何句柄,还是经由 `stat`/`list`——至少能观察到该前缀。
+每一次日志读写都流经返回的 `SessionHandle`;不存在按 id 寻址的 append 或 load 方法。`handle.read(offset?, length?)` 返回 `{ eventState, events }`:外层 slice 属于调用方,`eventState` 则区分独占的 `detached` event graph 与可能同时保存在 backend cache 中的 `shared-frozen` graph。该状态由生产者建立,slice 即使为空也会保留原状态。两种状态都能直接接管而无需复制;需要修改 event 的 consumer 必须先 clone。Read 绝不包含撕裂尾部,同一句柄上的重复读取绝不会观察到比先前读取更旧的状态,写句柄也能读到自己成功的 append。`handle.append(events)` 追加一个连续批次,其第一个 `seq` 等于已存储 next-seq;完成时的持久化是尽力而为的——批次被接受、有序,并对同一后端实例上的读取可见,只有完成的 `flush` 才承诺它在崩溃后依然存在(交付的 JSONL 后端恰好会立即持久化每个批次)。`handle.flush()` 是持久性屏障,同时把空的已创建会话实体化,使其可被持久列出。`handle.close()` 幂等且不可取消:读句柄释放本地资源;写句柄完成待处理的持久化并释放写所有权。一旦某次 `append` 或 `flush` 完成,其后在同一后端实例上开始的读取——无论经由任何句柄,还是经由 `stat`/`list`——至少能观察到该前缀。
 
 ### 所有权与可见性
 

+ 14 - 3
packages/session/session-persistence/src/handle.ts

@@ -4,7 +4,7 @@
  * @module @deepseek-ai/dsh-session-persistence/handle
  */
 
-import type { SessionEvent, SessionHeader, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
+import type { SessionEvent, SessionHeader, SessionId, SessionLogOffset, SessionSeedEventState } from '@deepseek-ai/dsh-session'
 
 /**
  * Log access granted by an open. `write` is read-write: the session's single
@@ -19,6 +19,17 @@ export interface SessionHandleReadOptions {
   readonly signal?: AbortSignal
 }
 
+/** One persistence event slice returned by {@link SessionHandle.read}. */
+export interface SessionHandleReadResult {
+  /**
+   * Whether event values are exclusively owned or shared only after deep
+   * freezing. Slicing preserves the producer's state even when no events remain.
+   */
+  readonly eventState: SessionSeedEventState
+  /** Event values in a caller-owned outer array. */
+  readonly events: readonly SessionEvent[]
+}
+
 /** Options for {@link SessionHandle.append}. */
 export interface SessionHandleAppendOptions {
   /** Optional cancellation observed before the write starts. */
@@ -67,9 +78,9 @@ export interface SessionHandle extends AsyncDisposable {
    * @param length - maximum number of events to return; defaults to the rest
    *   of the log. An offset at or past the end returns an empty list.
    * @param options - optional cancellation.
-   * @returns the events with `seq >= offset`, at most `length` of them.
+   * @returns the caller-owned outer slice plus the ownership state of its event values.
    */
-  read(offset?: number, length?: number, options?: SessionHandleReadOptions): Promise<readonly SessionEvent[]>
+  read(offset?: number, length?: number, options?: SessionHandleReadOptions): Promise<SessionHandleReadResult>
 
   /**
    * Append a contiguous batch continuing the current logical end. The first

+ 1 - 0
packages/session/session-persistence/src/index.ts

@@ -20,6 +20,7 @@ export type {
   SessionHandleAppendOptions,
   SessionHandleFlushOptions,
   SessionHandleReadOptions,
+  SessionHandleReadResult,
 } from './handle.ts'
 export {
   SessionAlreadyExistsError,

+ 24 - 20
packages/session/session-persistence/tests/contract.ts

@@ -152,13 +152,17 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
         // An empty batch is a no-op, not an error.
         await handle.append([])
         // A write handle reads its own successful appends.
-        expect(await handle.read()).toEqual(log)
-        expect(await handle.read(3)).toEqual(log.slice(3))
-        expect(await handle.read(0, 2)).toEqual(log.slice(0, 2))
-        expect(await handle.read(1, 3)).toEqual(log.slice(1, 4))
+        const full = await handle.read()
+        expect(full.events).toEqual(log)
+        if (full.eventState === 'shared-frozen') {
+          expect(full.events.every(event => Object.isFrozen(event) && Object.isFrozen(event.data))).toBe(true)
+        }
+        expect((await handle.read(3)).events).toEqual(log.slice(3))
+        expect((await handle.read(0, 2)).events).toEqual(log.slice(0, 2))
+        expect((await handle.read(1, 3)).events).toEqual(log.slice(1, 4))
         // At/past the stored end: an empty list, never an error.
-        expect(await handle.read(log.length)).toEqual([])
-        expect(await handle.read(log.length + 100)).toEqual([])
+        expect((await handle.read(log.length)).events).toEqual([])
+        expect((await handle.read(log.length + 100)).events).toEqual([])
         // flush after a durable append is a satisfied barrier, not an error.
         await handle.flush()
         await handle.close()
@@ -258,7 +262,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
         // After close, a new write handle continues at the stored next-seq.
         const writer = await persistence.open(m.id, 'write')
         await writer.append(secondTurn())
-        expect((await writer.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
+        expect((await writer.read()).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
         await writer.close()
       } finally {
         await dispose()
@@ -278,7 +282,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
         await expect(reader.append(secondTurn())).rejects.toBeInstanceOf(SessionReadOnlyError)
         await expect(reader.flush()).rejects.toBeInstanceOf(SessionReadOnlyError)
         // The refusals mutated nothing.
-        expect(await reader.read()).toEqual(oneTurnLog())
+        expect((await reader.read()).events).toEqual(oneTurnLog())
         await reader.close()
       } finally {
         await dispose()
@@ -343,13 +347,13 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
         const m = meta('lazy', '/work')
         const creator = await backend.persistence.create(m)
         // The creator's own reads see the empty log before materialization.
-        expect(await creator.read()).toEqual([])
+        expect((await creator.read()).events).toEqual([])
 
         const snapshot = await backend.persistence.stat(m.id)
         expect(snapshot?.header).toMatchObject(m)
         expect((await backend.persistence.list()).map(s => s.header.id)).toContain(m.id)
         const reader = await backend.persistence.open(m.id, 'read')
-        expect(await reader.read()).toEqual([])
+        expect((await reader.read()).events).toEqual([])
         await reader.close()
 
         if (backend.reopen !== undefined) {
@@ -397,7 +401,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
           expect((await reopened.persistence.list()).map(s => s.header.id)).toContain(m.id)
           expect((await reopened.persistence.stat(m.id))?.header).toMatchObject(m)
           const reader = await reopened.persistence.open(m.id, 'read')
-          expect(await reader.read()).toEqual([])
+          expect((await reader.read()).events).toEqual([])
           await reader.close()
         } finally {
           await reopened.dispose()
@@ -415,14 +419,14 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
         await writer.append(oneTurnLog())
 
         const before = await persistence.open(m.id, 'read')
-        expect(await before.read()).toEqual(oneTurnLog())
+        expect((await before.read()).events).toEqual(oneTurnLog())
 
         await writer.append(secondTurn())
         // Both a pre-existing read handle and a freshly opened one observe the
         // append once it has resolved.
-        expect((await before.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
+        expect((await before.read()).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
         const after = await persistence.open(m.id, 'read')
-        expect((await after.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
+        expect((await after.read()).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
         await before.close()
         await after.close()
         await writer.close()
@@ -444,7 +448,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
         try {
           const writer = await reopened.persistence.open(m.id, 'write')
           await writer.append(secondTurn())
-          expect((await writer.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
+          expect((await writer.read()).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
           await writer.close()
         } finally {
           await reopened.dispose()
@@ -469,7 +473,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
         ]
         await expect(handle.append(gapped)).rejects.toThrow(/expected 7/)
         // Neither rejection changed the stored log.
-        expect((await handle.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5])
+        expect((await handle.read()).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5])
         await handle.close()
       } finally {
         await dispose()
@@ -489,7 +493,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
         await expect(handle.append(bad(undefined))).rejects.toThrow(/losslessly JSON-serializable/)
         // The rejected batches left no events behind: seq 0 is still free.
         await handle.append(oneTurnLog())
-        expect(await handle.read()).toEqual(oneTurnLog())
+        expect((await handle.read()).events).toEqual(oneTurnLog())
         await handle.close()
       } finally {
         await dispose()
@@ -546,14 +550,14 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
         const readerInstance = await backend.reopen()
         try {
           const reader = await readerInstance.persistence.open(m.id, 'read')
-          expect(await reader.read()).toEqual(oneTurnLog())
+          expect((await reader.read()).events).toEqual(oneTurnLog())
           await reader.close()
 
           // A write open + first append durably truncates the torn tail and
           // continues at the committed next-seq.
           const writer = await readerInstance.persistence.open(m.id, 'write')
           await writer.append(secondTurn())
-          expect((await writer.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
+          expect((await writer.read()).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
           await writer.close()
         } finally {
           await readerInstance.dispose()
@@ -563,7 +567,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
         const verifyInstance = await backend.reopen()
         try {
           const verify = await verifyInstance.persistence.open(m.id, 'read')
-          expect((await verify.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
+          expect((await verify.read()).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
           await verify.close()
         } finally {
           await verifyInstance.dispose()

+ 1 - 1
packages/session/session-persistence/tests/live-write-contract.ts

@@ -27,7 +27,7 @@ export interface LiveWriteBackend {
 async function readAll(persistence: SessionPersistence, id: ReturnType<typeof SessionId>): Promise<readonly SessionEvent[]> {
   const reader = await persistence.open(id, 'read')
   try {
-    return await reader.read()
+    return (await reader.read()).events
   } finally {
     await reader.close()
   }

+ 1 - 0
packages/session/session-telemetry-otel/src/index.ts

@@ -258,6 +258,7 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend {
       if (committed === undefined) return
       const session = Session.fromRestore(
         snapshot.meta.id, snapshot.events, snapshot.meta, snapshot.inheritedEventCount,
+        'detached',
       )
       // fromRestore appends a lifecycle marker that this submission did not commit.
       if (isFeedback(session, committed)) coordinator.captureSession(session, committed.seq)

+ 1 - 1
packages/session/session-telemetry-otel/tests/otel.spec.ts

@@ -658,7 +658,7 @@ describe('OpenTelemetrySessionBackend route and feedback', () => {
       expect(ctx.sessions.list()).toEqual([])
       const read = await ctx.sessionPersistence.open(child.id, 'read')
       try {
-        const events = await read.read()
+        const { events } = await read.read()
         expect(events.at(-1)?.type).toBe('feedback/message-put')
         expect(events).toHaveLength(child.seq + 1)
       } finally {

+ 1 - 1
packages/session/session-telemetry/tests/telemetry.spec.ts

@@ -435,7 +435,7 @@ describe('SessionTelemetryCoordinator adoption', () => {
         isSeeded: false,
       },
       inheritedEventCount: SessionLogOffset(0),
-      seedSource: 'persistence',
+      eventState: 'detached',
     })
     ctx.sessions.enter(resumed)
     ctx.sessions.announce(resumed)

+ 1 - 1
packages/session/session-title/tests/persistence.spec.ts

@@ -42,7 +42,7 @@ async function appendPersistedTitle(ctx: Context, id: ReturnType<typeof SessionI
 async function expectPersistedTitle(ctx: Context, id: ReturnType<typeof SessionId>): Promise<void> {
   const handle = await ctx.sessionPersistence.open(id, 'read')
   try {
-    const events = await handle.read()
+    const { events } = await handle.read()
     expect(foldSessionTitle(events)).toMatchObject({
       title: 'Persist this session title',
       messageSeqs: [1],

+ 5 - 1
packages/subagent/subagent/tests/persistence-helpers.ts

@@ -10,7 +10,11 @@ export async function loadStoredSession(
 ): Promise<{ meta: SessionHeader; inheritedEventCount: SessionLogOffset; events: readonly SessionEvent[] }> {
   const handle = await persistence.open(id, 'read')
   try {
-    return { meta: handle.header, inheritedEventCount: handle.inheritedEventCount, events: await handle.read() }
+    return {
+      meta: handle.header,
+      inheritedEventCount: handle.inheritedEventCount,
+      events: (await handle.read()).events,
+    }
   } finally {
     await handle.close()
   }

+ 4 - 1
scripts/package-dependency-policy.ts

@@ -39,11 +39,14 @@ const DUPLICATE_SAFE_PACKAGES: readonly string[] = [
 
 /**
  * Runtime exports whose values remain valid when npm installs another package copy.
+ * New entries are forbidden by default. Automated agents must not add an
+ * exception; every addition requires explicit human review and a dedicated,
+ * prominent heading in the pull request description.
  */
 const SAFE_HOST_DEPENDENCY_EXPORTS = {
   '@deepseek-ai/dsh-credentials': ['credentialKey'],
   '@deepseek-ai/dsh-deque': ['Deque'],
-  '@deepseek-ai/dsh-llm': ['BlockAssembler', 'callConfigEquals', 'expandAssistantStream'],
+  '@deepseek-ai/dsh-llm': ['callConfigEquals'],
   '@deepseek-ai/dsh-session-format': ['sessionFormatLogFilename'],
   '@deepseek-ai/dsh-timeout': ['MAX_TIMER_DELAY_MS'],
   '@deepseek-ai/schemastery': ['default'],

+ 10 - 0
scripts/type-equiv.manifest.json

@@ -545,6 +545,11 @@
       "source": "packages/core/session/src/index.ts",
       "projection": "public-api"
     },
+    {
+      "doc": "docs/subsystems/persistence.md",
+      "symbol": "SessionHandleReadResult",
+      "source": "packages/session/session-persistence/src/handle.ts"
+    },
     {
       "doc": "docs/subsystems/persistence.md",
       "symbol": "SessionHandle",
@@ -560,6 +565,11 @@
       "symbol": "CreateSessionOptions",
       "source": "packages/core/session/src/types.ts"
     },
+    {
+      "doc": "docs/subsystems/persistence.md",
+      "symbol": "SessionSeedEventState",
+      "source": "packages/core/session/src/types.ts"
+    },
     {
       "doc": "docs/subsystems/persistence.md",
       "symbol": "RestoredSessionOptions",