# Agent Note: 在会话索引中记录最后活动

Status: proposed

[English](2026-07-29-durable-last-activity-index.md) | 中文

## 问题

一个冷会话（已持久化、未附加）对「用户上次是什么时候在这里发出 prompt」没有权威的已存储答案。`dsh-host-apiproxy` 从可选 projection cache 的 `lastPromptAt` 提供 `updatedAt`，缺失时回退到 `createdAt`，Web 客户端按该值为 Session 树排序。cache 采用 fail-soft 并异步写入 checkpoint，因此缺失或延迟的记录会让最近收到 prompt 的 Session 排得过旧。

网关以前会在可用时采用 JSONL 产物的 mtime。mtime 回答的是另一件事：这份产物上次是什么时候被写入。每一次持久写入都会刷新它，包括对撕裂尾部的截断修复、平衡中断轮次的合成 closer，以及拾起时追加的 [`session/end-seed` 边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)。这套近似会让 Session 仅仅因为被打开就提升排序。[有界冷空白验证](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md)移除了 mtime 排序，并把 cache 保守的「过旧」错误方向作为现阶段取舍。

已附加摘要可以折叠实时事件日志并选择最新的真人 `user/message`，但冷路径有意不读取大日志。为计算 `updatedAt` 而读取每一份日志，会让 `list()` 的开销随对话总字节数而非 Session 数量增长。用于 metadata 验证的 1 KiB 冷读取可以让符合条件的小产物得到精确的最近时间，但不能让大日志的排序精确。

让冷排序变得精确仍是一项持久格式决策，因此其范围留在本文，而不是网关 workaround 中。

## 提案

把最新真人 prompt 时间存到列举本就会读取的 Session 索引，这样 `summarizeCold()` 无需打开日志或依赖 cache checkpoint 就能给出答案。该值由协调器计算，因为它看得到每一次追加，而且本就拥有每 id 状态；由后端负责持久化。这样它就成为 `PersistenceBackend` 约定中新增的一个要素，而不是各后端本地账目，并与已附加投影使用同一个事件谓词：`source.kind` 为 `user` 的 `user/message`。

两个已交付的后端受到的约束正好相反，本提案对它们有意采取不对称的处理：

- **SQLite** 在 `sessions` 表上得到一列，与 `appendBatch` 在同一个事务中写入，代价是一次单调的 `SCHEMA_VERSION` 递增。
- **JSONL 无法承载一个可变的 header 字段。** header 就是第 1 行，在物化时一次写就，此后这份日志永远以追加方式打开；`jsonl.spec.ts` 钉住了「已提交的字节绝不重写」。一个每次追加都要改的 header 字段，违反的是一条被断言的持久性不变式，而不只是让写入方变复杂。要与「让 JSONL 保持近似」相比较的形态，是每会话一个伴随文件。

实现之前必须回答三个问题，本文对它们都没有定论：

**共享谓词由谁拥有？** 已存储字段在写入时编码规则，写入方只看到一个批次，而已附加摘要折叠整份日志。两者必须使用同一个导出的事件谓词或 reducer，避免新的消息来源变体让已附加排序与冷排序发生分歧。

**该字段引入之前的日志表现如何？** 既有产物里没有这个值。回退到 mtime 能让它们保持今天的准确度；回退到 `createdAt` 是诚实的，但会把选择器和会话树里每一个既有会话都重新排一次序。

**对 JSONL 来说伴随文件可以接受吗？** 它重新引入了每会话第二个文件，而该文件可能与日志不一致，这正是单产物设计所避开的。

## 考虑过的替代方案

**在冷路径上读取日志。** 它按构造就是正确的，也不需要改动格式，但会让只读 header 的列举失去意义：`list()` 的开销将随日志总体量增长，而 web 会话树会扇出到存储中的每一个会话。mtime 近似的存在，正是为了避开这个选项。

**保留 mtime，但把边界的写入排除在它之外。** 否决的理由是做不到，而不是不合意：mtime 属于文件系统，不属于后端。除了在每次边界写入之后把时间戳复原，没有别的办法能保住它，而那样做会与任何并发读取方产生竞态，也会对这份产物撒谎。

**仅在确实发生了修复时才写入边界。** 这能降低出现频率，而[边界 Agent Note](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)已经否决过它：谓词对有序重启同样必须成立。用一条正确性不变式去换时间戳的准确度，方向是错的。

**从投影缓存派生活动时间。** 这是当前的过渡实现。`session-projection-cache` 会折叠水位线之后的尾部，无需改变持久格式，但它是可选且 fail-soft 的。缺失或 checkpoint 延迟会让排序取决于 cache 是否存在以及是否新鲜，因此无法提供本文所提议的权威值。

## 验收标准

- 冷会话的 `SessionSummary.updatedAt` 等于已附加会话的投影为同一个会话报告的那个值；验证方式是恢复、不跑轮次就退出，并断言两条路径上的顺序都没有变化。
- 在 web 会话树和 TUI 恢复选择器中，一个恢复后即被弃置的会话不会排到此后工作过的会话之前；由一份组装后的快照钉住，而不是只靠单元测试。
- prompt 时间规则只有一个定义：一个测试证明，在包含真人 prompt、注入式 user message、边界和 closer 的日志上，已存储字段与已附加折叠结果一致。
- 在选定的回退方案下，该字段引入之前的产物能够无错误地加载和列举，并且该回退在排序上的后果有断言覆盖。
- 按本仓库不做迁移的立场，SQLite 的 `SCHEMA_VERSION` 递增会拒绝旧的磁盘版本。

## 风险

**prompt 时间的两个定义发生漂移。** 已存储字段按批次计算，而投影在整份日志上计算。一种新消息来源若在写入时按一种方式归类、在读取时按另一种方式归类，就会产生冷排序与已附加排序彼此矛盾的 Session；该缺陷只会在重启后显现。

**JSONL 的伴随文件可能与它的日志不一致。** 在日志追加与伴随文件写入之间发生崩溃，会留下一个陈旧的值，而且没有撕裂尾部标记可用来修复它。每个消费方都得把伴随文件当作一条提示来对待，而这与 mtime 今天的地位已经很接近了。

**回退方案会让既有会话重新排序。** 无论选定哪种回退，持有既有日志的用户都会在升级时看到自己的选择器和会话树重新排一次序。选 `createdAt` 会让这次重排的幅度很大。

**代价可能超过这个缺陷本身。** 剩余缺陷是 projection metadata 缺失或延迟时的保守错序。如果对 JSONL 来说诚实的答案是「保留 cache 回退」，那么本文的结局可能是记录该决定，而不是实现一个字段。

## 相关

- [有界冷空白验证](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md)——移除 mtime 排序，定义 projection cache 的过渡回退，并把直接冷读取限制为小产物 metadata 验证。
- [种子结束日志边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)——让 mtime 不适用的非 prompt 写入之一。
- [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)——仅追加与绝不重写这两条不变式，正是它们排除了可变的 JSONL header 字段。
- [共享持久化写入协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)——一个已存储字段将挂入的那条追加路径。
