# Agent Note: 在会话日志中记录恢复的进程边界

Status: rejected — 边界应当落在带种子 `Session` 的构造函数上，那里同时覆盖 fork 与回放；由[种子结束边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)取代

[English](2026-07-29-session-resumed-log-boundary.md) | 中文

## Problem

会话的持久日志此前无法证明它换过进程。`session/created`、`session/disposed` 和 `session/flush` 是 cordis 运行时信号，而不是 `SessionEventMap` 成员；`agent/session-start` 虽然携带 `SessionStartSource`，却只用于 emit，从不记录。因此，读取一份已存储日志得不到任何关于「曾经发生过恢复」的线索。

这一空缺让一类问题无法回答。在日志中拥有独立开始／结束事件对的插件必须区分两种未匹配的起始标记：一种由某个在操作中途死亡的进程留下，另一种正被当前某项操作持有；今天符合这一形态的只有压缩的 `compact/start` … `compact/end`。这两种状态**在已存储历史中逐字节相同**。没有边界，所有方只能在两种做法之间选择：永远拒绝（一个未匹配的标记会永久卡住该操作，而自动压缩失败采取警告并继续的策略，因此用户可见的结果是压缩静默停止工作，直到上下文窗口溢出），或者始终继续（这让持有锁失去了意义）。

修复它的压力是即刻的：把 `compact/start` 移到摘要生成之前这个真实的时间点，会把崩溃窗口从几微秒的同步追加扩大为一整次模型调用的时长，孤儿括号也就从罕见变为常态。

## Proposal

`@deepseek-ai/dsh-session-persistence` 声明唯一一个纯日志事件 `session/resumed`，其载荷为空，并在每次冷加载结束时恰好追加一条：与崩溃修复产生的 closers 同处一个 `commitRepair` 批次，且排在它们之后。因此，该边界之前的每个事件都有更小的 seq，并且都是由一个不再追踪这份日志的写入方写下的。所有权狭窄地落在 `loadCore()`，也就是 `load()` 与 `adopt()` 到达的冷加载路径。`loadLiveSnapshot()` 不追加任何内容，非变更性的 `inspect()`／`readFrom()` 读取也从不写入。

括号所有方求值的谓词纯粹是日志的函数：未匹配的起始标记之后有 `session/resumed` 的就是陈旧的，之后没有的就是存活的。

`time` 取 `Date.now()` 并以日志的最大 `time` 为下限，刻意区别于合成 closers——后者复用最后一个真实事件的时间戳，以便修复输出始终是已存储历史的确定性函数。挂钟并非单调：一次 NTP 跳变、一次虚拟机恢复，或一份从走快的机器上拷来的日志，都可能让它落在已存储事件之后，因此这个下限让跨边界的时长都非负。该下限是持久的，因为被钳制的边界本身会被存储并加入日志的最大值：一个未来时间的事件会把该日志中之后的每个边界都钉在同一时刻，直到挂钟时间越过它。

**该谓词区分的是进程接替，不是并发写入方。** `load()` 的存活性守卫是 `ctx.sessions.get(id)`，它只看到*本*运行时中存活的会话，而且没有任何后端会取跨进程的按会话锁。因此，进程 B 冷加载一个 A 当前拥有的会话时，会在 A 仍然开放的括号之后写入一个边界。必须容忍并发写入方的消费方仍然需要日志之外的存活信号。

## 为什么被否决

两个原因，都是在复审标记应当落在何处时发现的。

**它完全覆盖不到 fork。** `sessions.fork()` 与子代理 fork 子会话在不触及持久化的情况下构造带种子会话，因此两者都拿不到边界。fork 子会话会逐字节继承父会话的前缀——包括父会话仍然持有的开放 `compact/start`——而这恰恰是继承括号的所有方明显还活着的唯一情形。谓词偏偏在最需要它的地方不可用。

**在加载时铸造标记，把读取路径变成了持久写入。** 复审暴露出的每一项后果都源于此：每次冷加载都递增 revision、对一份无需修复的平衡日志也要走 `commitRepair`、上文那个持久时间下限、加载在只读存储上会失败，以及调用方随后取消的恢复也已留下标记。这些在该放置方式下都不算错，它们就是该放置方式的成本。

取代方案保留问题陈述与并发写入方的适用范围限制不变，并把写入移到 `Session` 的构造函数——全部六条带种子启动路径（含 fork）必经的唯一收窄处。由于标记随后走普通的种子持久化路径，上述整个持久写入面就消失了。

## Alternatives considered

**用 `Session.firstLiveSeq` 作为陈旧性谓词。** 此处以「文档明确它有意不做持久化，因此同一份已存储日志在不同进程中会给出不同答案，而只读读取方根本无法对它求值」为理由否决。这个推理对字段本身是成立的，但结论错了：正确的修法是持久化它的一个投影，而不是把边界挪到别处去算。这条替代方案正是后来的取代方案。

**在核心（`dsh-session`）中声明该事件。** 此处以「构造函数无法把恢复与 fork 或回放区分开」为理由否决。这句话是对的，但事实证明它无关紧要——并不需要这种区分，因为在这三种情形下继承历史都是死历史。

**教 `interruptedTurnClosers` 关闭 `compact/*`。** 否决：`compact/*` 是插件所属词汇，核心不得知道它。核心只关闭轮次、步骤和工具边界，也就是它自己拥有的关系。取代方案保留这条否决。

**惰性自修复：所有方发现孤儿时自行追加一条合成的关闭标记。** 这是在一次形似读取的检查中执行写入，而且需要为一个带轮次编号、其轮次却已经关闭的所有方开一个不变式例外。

**在核心中建一个可合并扩展的修复贡献方注册表。** 一旦出现第二个消费方，这就是正确的形状；今天只有一个消费方，而 `packages/AGENTS.md` 要求不要预先拆分 seam。

**仅在确实发生了修复时才写入边界。** 否决：该谓词对有序重启同样必须成立，而那时没有任何东西需要修复。取代方案保留这条否决。

## 相关

本提案记录过的冷会话 `updatedAt` 偏斜，范围界定在[最后活动索引 Agent Note](../../proposed/architecture/2026-07-29-durable-last-activity-index.md)。该缺陷早于本提案存在，并且在本提案被否决后依然存在：它的成因是 mtime 会计入每一次持久写入，而不是某一个边界。
