DSH / Atlas
2026-08-27implementedsimplificationarchived 2026-09-04

Persistence export() and pre-release read-path trims

持久化 export() 与预发布读取路径精简

The session-persistence seam is moving to a handle-based API with cross-process ownership (open/read/append/flush/close per session). Before that swap, the old seam carried surfaces the new design drops or replaces, each with its own consumers and tests: a consumer-facing path query (`locate`), a capability-flagged verbatim read (`supportsRawArtifacts` + `readRaw`), ~300 lines of same-version legacy event-shape migra

English

Problem

The session-persistence seam is moving to a handle-based API with cross-process ownership (open/read/append/flush/close per session). Before that swap, the old seam carried surfaces the new design drops or replaces, each with its own consumers and tests: a consumer-facing path query (locate), a capability-flagged verbatim read (supportsRawArtifacts + readRaw), ~300 lines of same-version legacy event-shape migration in the coordinator, and a locate-based size gate for the session list's cold blank probe. Removing them inside the seam swap would bloat an already large change; removing them first shrinks the core swap to the seam itself.

Decision

One verbatim export method. SessionPersistence.export(id, signal?) returns the session's raw artifact (SessionRawArtifact: parsed header, logical filename, decoded verbatim text) or undefined. The base default resolves undefined; JSONL overrides it with the former readRaw behavior. supportsRawArtifacts and readRaw do not exist. The apiproxy ZIP download distinguishes an unsupported backend (session present in list() but export() undefined → 501) from an absent session (404) by list membership instead of a capability flag. Superseded by the handle seam: the WebUI download needs only the logical log, so export() was removed entirely and the ZIP route serializes JSONL from a read handle, ending the 501 path.

No consumer-facing path query. locate is not a service method. SessionLocation survives only as refusal diagnostics: the JSONL backend derives the artifact path internally so SessionFormatUnsupportedError can point at the raw log a build refused. The three consumer features built on locate are removed or degraded, not ported:

  • DSH_SESSION_JSONL no longer exists; shell-env registers no persistence contributor. The variable was only honest with compression: 'none' — the default .jsonl.zstd artifact is unreadable from bash.
  • The Claude Code / Codex hook bridges keep transcript_path in the wire payload for protocol shape but always send '' / null. Hook scripts could not parse the compressed artifact either.
  • The locate-based size gate and the session-controller cold blank probe are deleted. The handle-based seam's stat()/list() snapshots may still carry eventCount/sizeBytes, but listing never opens a body from those hints and has no coldBlankProbeMaxEvents/coldBlankProbeMaxBytes configuration. A cold row without a current cache answer reports blank: false (unknown).

Legacy event-shape migration is deleted. Reads validate current v0 records only. Retired event types (steering/message, mode/set, request/header-delta) refuse through the read-side vocabulary gate as SessionFormatUnsupportedError. Pre-identity message payloads and the request/header fallback reason refuse through session validation — surfaced as SessionPersistenceCorruptionError on the load/inspect path and as the plain validation error on readFrom. Pre-react-loop turn envelopes have no validator: a stale turn/start.trigger field and the coarse aborted/disposed turn-end reasons load unprojected as extension-shaped data, the documented merge-extensible fall-through that the contract test "preserves extension turn/end reasons outside the closed reason set" pins. This consolidates and supersedes the pre-identity-message and pre-react-loop import notes; their record is preserved below.

Consolidated record of the deleted same-version imports

Two shipped read-side imports existed because message identity (2026-07) and the react-loop refactor (2026-08) changed durable payloads without bumping SESSION_FORMAT_VERSION: the coordinator normalized four exact pre-identity message payloads (minting deterministic legacy-message:<id>:<seq> identities, with tool-result replacements inheriting their target's id) and projected pre-react-loop shapes (steering/message → identified user/message, turn/start.trigger removal, terminal-reason mapping including a persistence-only { kind: 'legacy' } aborted cause). Both were read-only, exact-shape, and deliberately not a general v0 compatibility layer; their rejected alternatives were stranding first-party sessions, rewriting stored logs in place (violates append-only), and minting unstable identities.

They no longer justify their surface: no tagged release exists, the covered logs are months-old development artifacts, and the mechanism cost ~300 coordinator lines, per-event normalization on every read, a readFrom whole-prefix fallback for suffix reads, and fixture suites in three backends. The capability given up: pre-identity logs refuse to load (loudly, with the raw-log path in the refusal) instead of resuming, and pre-react-loop logs either refuse (when they carry the retired steering/message type) or load with their stale turn-envelope fields passed through instead of projected to current shapes. Reintroduction condition: after the first tagged release, a durable format change bumps SESSION_FORMAT_VERSION and ships an explicit migration under the version gate — never another same-version exact-shape exception. Absence is verified by the vocabulary-refusal tests in the coordinator contract and backend specs.

Alternatives considered

Keep locate as (or move it to) a separate export-location service. Rejected: all three path consumers are only functional with compression disabled, so the seam would preserve a half-broken feature; verbatim access needs are served by export().

Keep the supportsRawArtifacts capability flag beside export(). Rejected: undefined plus a list-membership check carries the same information with one seam member instead of three.

Keep the migrations until the first tagged release. Rejected: the pre-release stance ("remove at the first tagged release") already refuses old on-disk formats everywhere else; the migrations' only beneficiaries are development-era logs.

Consequences

The seam ahead of the handle refactor is smaller: one export method, no path query, no capability flag, and a coordinator without migration tables. The costs are recorded degradations: hook payload transcript_path is never populated (a durable consumer gap in both hook bridge READMEs), the session list marks never-opened cold sessions blank only within the snapshot-metadata probe thresholds, and development-era logs written before the react-loop refactor refuse to load. The 501/404 split for ZIP export costs one list() call on the undefined-export path only.

Related

中文

问题

会话持久化 seam 正在迁移到基于句柄、具备跨进程所有权的 API(每个会话的 open/read/append/flush/close)。在这次替换之前,旧 seam 携带着新设计将放弃或替换的多个表面,每个都有自己的消费方与测试:面向消费方的路径查询(locate)、由能力标志控制的逐字读取(supportsRawArtifacts + readRaw)、协调器中约 300 行的同版本 legacy 事件形态迁移,以及会话列表冷空白探测使用的基于 locate 的大小门槛。若在 seam 替换中一并移除它们,会让本已庞大的变更进一步膨胀;先移除它们能把核心替换缩小到 seam 本身。

决策

单一逐字导出方法。SessionPersistence.export(id, signal?) 返回该会话的原始产物(SessionRawArtifact:解析后的 header、逻辑文件名、解码后的逐字文本)或 undefined。基类默认解析为 undefined;JSONL 用原先的 readRaw 行为覆盖它。supportsRawArtifacts 与 readRaw 不复存在。apiproxy 的 ZIP 下载通过 list 成员关系而非能力标志区分不受支持的后端(会话存在于 list() 中但 export() 为 undefined → 501)与不存在的会话(404)。已被句柄 seam取代:WebUI 下载只需要逻辑日志,因此 export() 被整体移除,ZIP 路由改为从读句柄序列化 JSONL,501 路径随之终结。

不提供面向消费方的路径查询。locate 不是服务方法。SessionLocation 仅作为拒绝诊断保留:JSONL 后端在内部推导工件路径,使 SessionFormatUnsupportedError 能指向构建所拒绝的原始日志。基于 locate 构建的三项消费方功能被移除或降级,而非移植:

  • DSH_SESSION_JSONL 不复存在;shell-env 不再注册持久化贡献方。该变量只有在 compression: 'none' 时才是诚实的——默认的 .jsonl.zstd 产物无法从 bash 读取。
  • Claude Code/Codex 钩子桥接层为保持协议格式,仍在线上 payload 中保留 transcript_path,但始终发送 ''/null。钩子脚本同样无法解析压缩产物。
  • session-controller 中基于 locate 的大小门槛和冷空白探测都被删除。基于句柄的 seam 的 stat()/list() 快照仍可携带 eventCount/sizeBytes,但 listing 不会根据这些提示打开正文,也没有 coldBlankProbeMaxEvents/coldBlankProbeMaxBytes 配置。没有当前 cache 答案的冷 row 报告 blank: false(未知)。

**删除 legacy 事件形态迁移。**读取只校验当前 v0 记录。已废弃的事件类型(steering/message、mode/set、request/header-delta)经由读取侧词汇门禁以 SessionFormatUnsupportedError 拒绝。消息标识机制之前的消息 payload 与 request/header 的 fallback 原因经由会话校验拒绝——在 load/inspect 路径上表现为 SessionPersistenceCorruptionError,在 readFrom 上表现为普通校验错误。react-loop 重构之前的轮次 envelope 没有校验器:过时的 turn/start.trigger 字段与粗粒度的 aborted/disposed 轮次结束原因会作为扩展形态数据不经投影地加载,这正是约定测试 "preserves extension turn/end reasons outside the closed reason set" 所钉住的、有文档记载的可合并扩展 fall-through。此举合并并取代了 pre-identity-message 与 pre-react-loop 两份导入 Note;其记录保存在下文。

已删除的同版本导入的合并记录

曾存在两个已上线的读取侧导入,因为消息标识机制(2026-07)与 react-loop 重构(2026-08)在未升级 SESSION_FORMAT_VERSION 的情况下改变了持久 payload:协调器会归一化四种精确的 pre-identity 消息 payload(铸造确定性的 legacy-message:<id>:<seq> 标识,工具结果替换项继承其目标的 id),并投影 pre-react-loop 形态(steering/message → 带标识的 user/message、移除 turn/start.trigger、终止原因映射——包括仅存在于持久化中的 { kind: 'legacy' } aborted 原因)。二者都是只读、精确形态匹配,并且有意不构成通用的 v0 兼容层;当时被否决的替代方案是弃置第一方会话、就地改写已存储日志(违反仅追加)以及铸造不稳定的标识。

它们已不足以支撑自身的表面:尚无任何已打标签的发布,所覆盖的日志是数月前的开发期产物,而该机制的代价是协调器约 300 行代码、每次读取的逐事件归一化、后缀读取时 readFrom 回退到整个前缀,以及三个后端中的 fixture(测试前置数据)套件。放弃的能力是:pre-identity 日志会拒绝加载(明确报错,并在拒绝信息中给出原始日志路径),而不是继续恢复;pre-react-loop 日志则要么被拒绝(当其携带已废弃的 steering/message 类型时),要么在加载时把过时的轮次 envelope 字段原样透传,而不是投影为当前形态。重新引入条件:在第一个已打标签的发布之后,持久格式变更升级 SESSION_FORMAT_VERSION 并在版本门禁之下提供显式迁移——绝不再开一个同版本精确形态的例外。协调器约定与后端 spec 中的词汇拒绝测试验证该机制确实不存在。

考虑过的替代方案

**保留 locate(或将其移入独立的导出位置服务)。**不予采用:三个路径消费方都只有在禁用压缩时才可用,seam 会因此保留一个半失效的功能;逐字访问需求已由 export() 满足。

**在 export() 之外保留 supportsRawArtifacts 能力标志。**不予采用:undefined 加上 list 成员检查携带相同的信息,却只需一个 seam 成员而非三个。

**把迁移保留到第一个已打标签的发布。**不予采用:预发布立场(「在第一个已打标签的发布时移除」)已在其他所有地方拒绝旧的磁盘格式;这些迁移的唯一受益者是开发期日志。

后果

句柄重构之前的 seam 更小:一个导出方法、没有路径查询、没有能力标志,以及不含迁移表的协调器。代价是已记录在案的降级:钩子 payload 的 transcript_path 永远不会被填充(两个钩子桥接层 README 中记录的持久消费方缺口);会话列表只在快照元数据探测阈值之内才把从未打开过的冷会话标记为空白;react-loop 重构之前写入的开发期日志会拒绝加载。ZIP 导出的 501/404 区分只在 export() 为 undefined 的路径上多一次 list() 调用。

相关资料