DSH / Atlas
2026-06-19implementedsimplification

Drop the mutable session summary

移除可变的会话摘要

The [session-persistence seam](../architecture/2026-06-14-session-persistence.md) split a session's out-of-log metadata into two types owned by `dsh-session`: an immutable `SessionHeader` (`version`, `id`, `createdAt`, `cwd?`, `parentSession?`) written once at creation, and a mutable `SessionSummary` (`updatedAt`, `title?`, `firstPrompt?`) "updateable without touching the append-only log". Their union was `SessionMet

English

Problem

The session-persistence seam split a session's out-of-log metadata into two types owned by dsh-session: an immutable SessionHeader (version, id, createdAt, cwd?, parentSession?) written once at creation, and a mutable SessionSummary (updatedAt, title?, firstPrompt?) "updateable without touching the append-only log". Their union was SessionMeta = SessionHeader & SessionSummary, and the abstract SessionPersistence service carried a seventh method — update(id, summary) — for rewriting the summary. Each backend implemented the mutable store its own way: JSONL wrote a separate atomic .summary.json sidecar beside the log (temp-write + rename, best-effort), SQLite kept updated_at/title/first_prompt columns bumped inside the append transaction.

The summary was designed for a future session picker (recency ordering via updatedAt, a title/firstPrompt preview). That picker was never built. An audit of the whole repo found the entire SessionSummary API is dead state:

  • SessionPersistence.update() has zero production callers (every .update( hit is createHash().update() or a test).
  • firstPrompt is never read anywhere in production.
  • Session titles come from durable session/title events, while tool-card titles come from tool presenters; neither reads mutable session metadata.
  • Persistence-list consumers use immutable header identity, creation, lineage, and cwd fields. Recency and previews derive from the log rather than an updatedAt summary.
  • Decisively: the live Session.header was already typed SessionHeader, not SessionMeta — the summary never existed on the live session object; it lived only in the persistence layer, written and read by nothing but its own contract test.

Decision

Delete the mutable session summary entirely. SessionSummary and the SessionMeta name are removed; the metadata a backend stores and returns is just SessionHeader. SessionPersistence.update() is removed from the abstract service and every backend. JSONL loses the whole sidecar machinery (writeSidecar/readSidecar/touchSummary/removeSidecars/sidecarPath and the load/list overlays); SQLite drops the updated_at/title/first_prompt columns and the per-append updated_at bump, and its SCHEMA_VERSION goes 1 → 2.

Anything the summary was meant to provide is derivable from the append-only log when a consumer actually needs it (firstPrompt = first user/message; recency = the last event's time or the file mtime) or already lives in the immutable header (createdAt, cwd). The one thing not derivable — a user-edited title — had no implementation and is pure YAGNI; it can return as its own log event or header field if a real feature ever needs it.

The removal narrows a public service contract and an on-disk format across two backends; the summary was a deliberate forward-looking design, not an accident; and SessionHeader now stands where the original Agent Note described SessionMeta, which is why the summary vanished. It also unblocks the shared persistence write coordinator: with no mutable summary, the coordinator's hook interface needs no updateSummary hook and the JSONL-sidecar-vs-SQLite-column durability divergence disappears, so the two backends' write paths converge.

No migration

This is unreleased software (see root AGENTS.md § "Pre-release stance: foundation over blast radius"), so there are no on-disk databases or logs to preserve. SQLite does not migrate a v1 database: the openDatabase guard now rejects any non-current on-disk user_version (onDisk !== 0 && onDisk !== SCHEMA_VERSION) — older or newer — so a stale v1 DB is cleanly rejected rather than half-read against the new column set. A fresh database stamps the current version; that is the only path that needs to work.

Consequences

A future session picker now has to derive its preview/ordering from the log (or reintroduce a typed field) rather than reading a ready-made summary row. That is the correct cost: a cache for a feature that does not exist is dead weight that every backend pays to maintain and every contract test pays to assert. The principle — a passing test pins current behavior, not necessarily correct behavior; behavior can be an artifact of a past compromise — is now recorded as a standalone convention in root AGENTS.md, with this change as its worked example.

<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->

中文

问题

会话持久化 seam 将会话的日志外元数据拆分为 dsh-session 拥有的两种类型:一个不可变的 SessionHeaderversionidcreatedAtcwd?parentSession?),在创建时一次性写入;一个可变的 SessionSummaryupdatedAttitle?firstPrompt?),「可在不触碰仅追加日志的情况下更新」。二者合并为 SessionMeta = SessionHeader & SessionSummary,抽象的 SessionPersistence 服务为此多出第七个方法 update(id, summary),用于重写摘要。各后端各自实现可变存储:JSONL 在日志旁先写入临时文件再重命名,并以尽力而为的方式原子发布一个独立的 .summary.json 伴随文件;SQLite 则使用 updated_at/title/first_prompt ,并在追加事务内更新其中的时间列。

摘要是为未来的会话选择器设计的(通过 updatedAt 排序近期会话,用 title/firstPrompt 做预览)。该选择器从未实现。对整个仓库的审计表明,SessionSummary 的整套相关接口都只是在维护无用状态

  • SessionPersistence.update() 零个生产调用方(所有 .update( 匹配都是 createHash().update() 或测试代码)。
  • firstPrompt 在生产代码中从未被读取
  • 会话标题来自持久的 session/title 事件,工具卡片标题来自工具 presenter;二者都不读取可变的会话元数据。
  • 持久化列表的消费方使用不可变 header 中的标识、创建时间、谱系和 cwd 字段。近期排序和预览派生自日志,而非某个 updatedAt 摘要。
  • 决定性的一点:活跃的 Session.header 类型本来就是 SessionHeader 而非 SessionMeta——摘要从未存在于活跃会话对象上;它只存在于持久化层,除了自身的约定测试外无人写入、无人读取。

决策

彻底删除可变的会话摘要。SessionSummarySessionMeta 这个名称一并移除;后端存储和返回的元数据仅为 SessionHeaderSessionPersistence.update() 从抽象服务和所有后端中移除。JSONL 去掉整套伴随文件机制(writeSidecar/readSidecar/touchSummary/removeSidecars/sidecarPath 以及 load/list 的覆盖逻辑);SQLite 去掉 updated_at/title/first_prompt 列以及每次追加时的 updated_at 更新,其 SCHEMA_VERSION1 → 2

摘要原本要提供的一切,在消费方真正需要时都可从仅追加日志中派生firstPrompt = 第一条 user/message;近期度 = 最后一个事件的 time 或文件 mtime),或者已经存在于不可变 header 中(createdAtcwd)。唯一不可派生的是用户手动编辑的标题,但它从未实现,纯属 YAGNI;如果未来真有功能需要,它可以作为独立的日志事件或 header 字段回归。

这次移除同时收窄两个后端的公开服务约定和磁盘格式;摘要是有意为未来设计的结果,而非意外;如今原 Agent Note 描述 SessionMeta 之处已是 SessionHeader,这就是摘要消失的原因。它还为共享持久化写入协调器扫清障碍:不再有可变摘要后,协调器的钩子接口不需要 updateSummary 钩子,JSONL 伴随文件与 SQLite 列之间的持久性分歧也随之消失,使两个后端的写入路径趋于一致。

无需迁移

这是未发布的软件(见根 AGENTS.md「Pre-release stance: foundation over blast radius」一节),因此没有需要保留的磁盘数据库或日志。SQLite 不迁移 v1 数据库:openDatabase 守卫现在拒绝任何非当前版本的磁盘 user_versiononDisk !== 0 && onDisk !== SCHEMA_VERSION),无论版本更旧还是更高,因此陈旧的 v1 数据库会被干净地拒绝,而不会按新的列集合进行不完整读取。新建数据库写入当前版本号;这是唯一需要正常工作的路径。

后果

未来的会话选择器现在必须从日志派生预览/排序信息(或重新引入一个类型化字段),而不能直接读取现成的摘要行。这是正确的代价:为一个尚不存在的功能维护缓存,是每个后端都要承担维护成本、每个约定测试都要承担断言成本的无谓负担。这一原则——通过的测试固定的是当前行为,不一定是正确行为;行为可能是过去妥协的产物——现已作为独立约定记录在根 AGENTS.md 中,本次变更即为其实例。

<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->