Files
deepseek-harness/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md
T

6.6 KiB
Raw Blame History

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

Status: rejected — 边界应当落在带种子 Session 的构造函数上,那里同时覆盖 fork 与回放;由种子结束边界取代

English | 中文

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。该缺陷早于本提案存在,并且在本提案被否决后依然存在:它的成因是 mtime 会计入每一次持久写入,而不是某一个边界。