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

Status: proposed

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

## 问题

一个冷会话（已持久化、未附加）对「上次是什么时候在这里面工作过」没有任何已存储的答案。因此 `dsh-host-apiproxy` 的 `summarizeCold()` 在存在日志文件时用它的 mtime 来近似它——`locate()` 为 JSONL 解析出一个逐会话产物，为 SQLite 解析出 `undefined`，而 SQLite 的冷会话会回退到 `createdAt`——而 web 客户端就按由此得到的 `updatedAt` 为自己的会话树排序。这两个后端错的方向正好相反：JSONL 读出来偏新，SQLite 偏旧。

mtime 回答的是另一个问题：这份产物上次是什么时候被写入的。每一次持久写入都会刷新它，包括那些并不是活动的写入：一次对撕裂尾部的截断修复、用来平衡被中断的轮次的那些合成 closer，以及带种子的会话会追加的 [`session/end-seed` 边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)。（没有待处理内容的 `flush` 不在其中：协调器在到达后端之前就返回了。）用户可见的后果是稳定的，而且只朝一个方向错：一个被触碰过却没有在里面工作过的会话，会把自己排到用户此后真正工作过的那些会话之前，而且每次触碰都会重新把它排上去一次。「触碰」比「恢复」的范围更宽——`dsh-host-apiproxy` 的 `agentFor()` 会在首次触碰时恢复一个冷会话，而 web 客户端仅仅打开一个会话时 `sessions.history` 就会到达它，因此普通的浏览就够了。

已附加会话的那个投影有真正的修复办法（`lastActivityTime()` 会跳过边界），但它需要事件日志，而冷路径有意不去读日志。为计算 `updatedAt` 而读取日志，会让只读 header 的列举失去意义，而正是它让 `list()` 的开销随会话数量而非日志体量增长。

[边界那次变更](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)提高了这个缺陷的出现频率，因为一次拾起如今会在此前完全无写入的路径上产生写入；`dsh-host-apiproxy` 的 README 已在 Known Limitations 中记录该项。它并没有引入这套近似做法，而移除这套近似是一项持久格式决策，因此它的范围划在本文，而不是那里。

## 提案

把最后活动时间存到列举本就会读取的地方，也就是会话索引，这样 `summarizeCold()` 无需打开日志就能给出答案。该值由协调器计算，因为它看得到每一次追加，而且本就拥有每 id 状态；由后端负责持久化。这样它就成为 `PersistenceBackend` 契约中新增的一个要素，而不是各后端本地的账目，同时让「活动」只保留一个定义，与日志内的 `lastActivityTime()` 共用。

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

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

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

**哪些事件算作活动？** 对日志而言，`lastActivityTime()` 通过排除 `session/end-seed` 回答了这个问题。一个已存储字段是在写入时编码这条规则的，而写入方在那里只看到一个批次，不是整份日志。两者不得发生漂移，否则已附加表层与冷表层会对同一个会话给出彼此矛盾的答案。

**该字段引入之前的日志表现如何？** 既有产物里没有这个值。回退到 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` 本就会折叠水位线之后的尾部，因此一个最后活动单元可以搭乘既有机制。它作为主形态被否决，因为该缓存是一个可选的组合项；只有挂载了缓存插件才提供的列举，会让排序取决于如何组合。

## 验收标准

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

## 风险

**「活动」的两个定义发生漂移。** 已存储字段按批次计算，而投影在整份日志上计算。一种新事件类型若在写入时按一种方式归类、在读取时按另一种方式归类，就会产生一个冷排序与已附加排序彼此矛盾的会话；这个缺陷只在重启之后才显现，而那正是最难被注意到的地方。

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

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

**代价可能超过这个缺陷本身。** 该缺陷是被弃置会话的排序出错。如果对 JSONL 来说诚实的答案是「保留这套近似」，那么本文的结局可能是记录下这个决定，而不是实现一个字段，而这也是一个可以接受的结局。

## 相关

- [种子结束日志边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)——mtime 会计入的非活动写入之一；`dsh-session` 拥有 `lastActivityTime()`，也就是一个已存储字段必须与之保持一致的那个日志内投影。
- [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)——仅追加与绝不重写这两条不变式，正是它们排除了可变的 JSONL header 字段。
- [共享持久化写入协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)——一个已存储字段将挂入的那条追加路径。
