Status: implemented
English | 中文
在会话日志中拥有独立开/闭括号的插件无法区分一个已死的标记和一个存活的标记。compaction/start … compaction/end 就是已发布的实例:当接手一份日志、而它最后的压缩(compaction)事件是一个未配对的 compaction/start 时,「上一个写入方在压缩中途死掉了」与「此刻正有一次压缩在运行」在存储历史中是逐字节相同的。该括号所有方只能二选一:拒绝压缩一份其实空闲的日志(把会话卡死),或者在一份确实繁忙的日志上继续压缩。
日志中没有任何东西标出继承历史在哪里结束。session/created、session/disposed 与 session/flush 是 Cordis 运行时信号,不是日志事件;agent/session-start 只发射不落盘。Session.firstLiveSeq 本来就精确地持有这个答案——本生命周期第一次自有写入的 seq——但只存在于内存中,因此读取存储字节的消费方看不到它。
崩溃修复既没有填上这个缺口,也不应该去填:interruptedTurnClosers 合成轮次、步骤与工具边界,是因为核心拥有那套词汇表,而 compaction/* 属于压缩 seam。一个会关闭插件括号的核心修复流程,等于把每个插件的括号语义都搬进核心。
Session 的构造函数紧接显式传入的构造种子(包括空种子)之后追加仅日志事件 session/end-seed,作为带种子会话的第一次自有写入,位置正是 firstLiveSeq 指出的 seq。该事件是那个字段的持久投影:firstLiveSeq 为持有对象的消费方回答本生命周期的写入从哪里开始,session/end-seed 则为只持有存储字节的消费方回答同一问题。它的 payload 为空——位置与 time 承载全部含义——并且不是 SurfaceEventType,因此不产生消息,也无法扰动派生历史。这个 seq-0 标记把从空日志恢复的会话与真正的全新会话区分开来,从而防止恢复期间应用新会话默认值。
括号所有方按位置读取它:在 session/end-seed 之前的未配对开启标记具有更小的 seq,来自构造种子,并且属于一个已结束的生命周期。核心写入该边界但不从中读取任何内容;每个括号的词汇表仍归其所属插件,因此在没有消费方来塑形之前,核心不会先发布谓词辅助函数。
选择构造函数,是因为它是每一个带种子会话都必经的唯一收窄处。全部六个入口都会到达它:agents.resume()、在已持久化 id 上的配置驱动启动(restoreOrCreateConfigured)、sessions.fork()、subagent fork 子会话、coordinator.adopt() 的实时前缀路径,以及裸的 sessions.create(id, {seed})。在持久化加载时写入的边界会漏掉两条 fork 路径——而一个继承了仍在运行的父会话开放 compaction/start 的 fork 子会话,恰恰是必须可判定的场景。在 loop 启动时写入的边界会漏掉 fork() 与 adopt(),并且不得不在 SessionStartSource: 'startup' 上触发——那正是 fork 子会话发布的取值,于是该字段将不再具有区分力。
两条守卫让这个标记保持精确。省略种子时不写入任何内容,因为这是全新会话。种子本身已以该事件结尾时不会重复标记,这让写入具备幂等性。幂等性是承重的,而不是为了整洁:每次绑定到 Agent 的冷会话接手都会经过 agentFor();没有这条守卫,重复的控制操作即使没有执行任何工作,也会让日志增长。只执行检查的 session.history 与 session.fork 源端路径不会在源会话中创建这条边界。
构造函数中的 append 发生在 enter() 之前,因此会话尚无 store attachment:该标记不会在 session/event 上发布,与它之前的种子事件完全一样。它属于 initFor 捕获的那份创建种子,并通过普通的种子路径落盘——onCreated 的 createCore + appendCore,或无主认领的后缀写入。因此监听 firehose 的消费方永远看不到这条边界,必须从日志中读取它。
对 seam 的影响:load() 仍是纯读取,没有 revision 递增,对平衡日志不走 commitRepair,被拒绝的 append 也不留下持久标记。但接手不是纯读取——如今一次拾起会在此前完全无写入的路径上产生写入,因此只读存储或磁盘写满会在 session/created 处报错,而不是在第一个真实轮次处。这是本放置方式新增的唯一成本,并且比加载路径方案的成本更窄(后者会让加载本身失败)。
若崩溃发生在种子写入到达磁盘之前,边界会丢失,而这没有代价:待处理批次按序写入,所以丢掉一个边界意味着它之后的每个事件也一起丢掉。下一次接手读到的字节与上一次相同,会追加自己的边界,并对括号作出完全相同的判定。进程内消费方应优先使用 firstLiveSeq,它在任何写入之前就是精确的。
该谓词对本会话继承的括号成立,而不是关于其他写入方的存活信号。一个并发存活的会话可能在同一段存储历史上持有开放括号,而它自己的边界在别处。必须容忍并发写入方的消费方需要日志之外的存活信号,不能仅凭这个事件就省掉它。
由持久化协调器的冷加载路径写入边界。 较早的一版迭代写入的是 session/resumed 边界;它落选,一是因为完全覆盖不到 fork——而 fork 恰恰是继承括号的所有方可能仍然存活的那一种情形——二是因为在加载时铸造的标记必须在读取路径上做持久写入,这把成本铺开到整个 seam:每次冷加载都递增 revision、对一份无需修复的平衡日志也要走 commitRepair、需要一个已存储时间下限来维持钳制的单调性,以及加载在只读存储上会失败。
在 loop 启动时追加边界。 loop 调用 resumeWith,因此覆盖恢复路径,但完全漏掉 fork() 与 adopt(),而且事件不得不在 'startup' 上触发——那是 fork 子会话发布的来源——于是 SessionStartSource 将不再具有区分力。它还会在追加标记之前就发布会话,因此 session/created 监听方可能观察到一份没有边界的带种子日志。
复用 header.seedLength。 它是持久的 fork 血缘边界,并且刻意在恢复时保留原始 fork 取值——而恢复时构造种子是整份存储日志。这两个事实并不相同,混同会同时失去两者。
让崩溃修复连同轮次边界一起关闭 compaction/*。 否决:这会把每个插件的括号语义搬进核心的修复流程,而核心无法知道关闭另一个包的括号应该记录什么。
买到的:一条边界,在一处写入,对全部六条带种子启动路径都正确——包括持久化层方案触及不到的 fork 缺口。持久化各包保留纯读取路径。firstLiveSeq 获得一个持久孪生体,而不是关于同一边界的第二套彼此竞争的概念。
代价:带种子会话的日志长了一个事件,空日志恢复也包括在内。seq 期望会随这条边界移动。两处更新是承重的而非机械的:telemetry 的接管测试断言该边界会被导出,因为它是本生命周期的自有写入;属性测试套件的回放不变式则是「种子逐字节复现,外加一个仅日志边界」,并把幂等性作为独立属性。
session/end-seed 加入了落盘词汇表。在预发布立场下(SESSION_FORMAT_VERSION 固定为 0,不作兼容承诺),更旧的日志只是没有它,而没有边界的日志会正确地判定没有任何内容属于构造种子历史。
排队手动压缩决策如今提供了第一个消费方。其尾部扫描会分别查找未匹配的 compaction/start 与最新 end-seed,只把位于该边界之后的 start 视为存活,并在同一个回放转换上清除不变量追踪状态。该谓词仍位于压缩功能所在的包中,不会成为通用核心辅助函数。