DSH / Atlas
2026-07-30implementedarchitecture

the end-seed log boundary

种子结束日志边界

A plugin that owns a standalone open/close bracket in the session log cannot tell a dead marker from a live one. `compaction/start` … `compaction/end` is the shipped case: on picking up a log whose last compaction event is an unmatched `compaction/start`, "the previous writer died mid-compaction" and "a compaction is running right now" are byte-identical stored history. The owner must either refuse to compact a log t

English

Problem

A plugin that owns a standalone open/close bracket in the session log cannot tell a dead marker from a live one. compaction/startcompaction/end is the shipped case: on picking up a log whose last compaction event is an unmatched compaction/start, "the previous writer died mid-compaction" and "a compaction is running right now" are byte-identical stored history. The owner must either refuse to compact a log that is actually free (wedging the session) or proceed over one that is genuinely busy.

Nothing in the log marked where inherited history ended. session/created, session/disposed, and session/flush are cordis runtime signals, not log events; agent/session-start is emit-only. Session.firstLiveSeq already held the answer exactly — the seq of this lifecycle's first own write — but only in memory, so a consumer reading stored bytes could not see it.

Crash repair does not close the gap and must not: interruptedTurnClosers synthesizes turn, step, and tool boundaries because core owns that vocabulary, and compaction/* belongs to the compaction seam. A core repair pass that closed plugin brackets would put every plugin's bracket semantics in core.

Decision

Session's constructor appends the log-only session/end-seed event immediately after an explicitly supplied constructor seed, including an empty one, as the seeded session's first live write at the seq firstLiveSeq names. The event is the durable projection of that field: firstLiveSeq answers where this lifecycle's writes start for a consumer holding the object, while session/end-seed answers the same question for one holding only stored bytes. Its payload is empty — position and time carry the whole meaning — and it is not a SurfaceEventType, so it produces no message and cannot perturb derived history. The seq-0 marker distinguishes an empty resumed session from a genuinely fresh session, preventing new-session defaults from being applied during resume.

A bracket owner reads it positionally: an unmatched opening marker before session/end-seed has a smaller seq, came from the constructor seed, and belongs to a lifecycle that has ended. Core writes the boundary and reads nothing from it; each bracket's vocabulary stays with its owning plugin, so no core predicate helper ships without a consumer to shape it.

The constructor is the placement because it is the single waist every seeded session passes through. All six entry points reach it: agents.resume(), config-driven startup on a persisted id (restoreOrCreateConfigured), sessions.fork(), a subagent fork child, coordinator.adopt()'s live-prefix path, and a bare sessions.create(id, {seed}). A boundary written at persistence load would miss both fork paths — and a forked child inheriting a still-running parent's open compaction/start is precisely the case that must be classifiable. A boundary written at loop start would miss fork() and adopt(), and would have to fire on SessionStartSource: 'startup', which is what a fork child publishes, so that field would stop discriminating.

Two guards keep the marker precise. An omitted seed writes nothing because the session is fresh. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness: each Agent-bound pickup of a cold session passes through agentFor(), and without the guard repeated controls would grow the log even when they perform no work. The inspection-only session.history and session.fork source paths do not create this boundary in the source.

Persistence needs no changes

The constructor append happens before enter(), so the session has no store attachment: the marker never publishes on session/event, exactly like the seed events before it. It is instead part of the log initFor captures as the creation seed, and persists through the ordinary seed path — onCreated's createCore + appendCore, or the ownerless-claim suffix write. A consumer that watches the firehose therefore never sees the boundary and must read it from the log.

Consequences for the seam: load() stays a pure read, with no revision bump, no commitRepair on a balanced log, and no durable mark left by a rejected append. Attaching is not a pure read, though — a pickup now writes where nothing was written before, so a read-only or full disk fails at session/created rather than at the first real turn. That is the one cost this placement adds, and it is narrower than the load-path version's (which failed the load itself).

A crash before the seed write reaches disk loses the boundary, and that costs nothing: the pending batch is written in order, so a lost boundary means every event after it is lost too. The next pickup reads the same bytes the previous one did, appends its own boundary, and classifies the bracket identically. In-process consumers should prefer firstLiveSeq, which is exact before any write.

Scope of the guarantee

The predicate holds for a bracket this session inherited, not as a liveness signal about other writers. A concurrently live session may hold an open bracket over the same stored history while its own boundary sits elsewhere. A consumer that must tolerate concurrent writers needs a liveness signal beyond the log and cannot omit it on the strength of this event.

Alternatives considered

A boundary written by the persistence coordinator's cold-load path. An earlier iteration wrote a session/resumed boundary; it lost because it covers no fork — the one case where the inherited bracket's owner may still be running — and because a marker minted at load had to be a durable write on a read path, which spread cost across the seam: a revision bump on every cold load, a commitRepair batch on a balanced log with nothing to repair, a stored-time floor to keep the clamp monotonic, and a load that failed against a read-only store.

A boundary appended at loop start. The loop calls resumeWith, so it covers the resume paths, but it misses fork() and adopt() entirely, and the event would have to fire on 'startup' — the source a fork child publishes — so SessionStartSource would stop discriminating. It also publishes the session before the marker is appended, so a session/created listener could observe a seeded log with no boundary.

Reusing header.seedLength. It is the durable fork-lineage boundary and deliberately keeps the original fork value across a resume, where the constructor seed is the whole stored log. The two facts differ and conflating them would lose both.

Crash repair closing compaction/* alongside turn boundaries. Rejected: it moves every plugin's bracket semantics into core's repair pass, and core cannot know what closing another package's bracket should record.

Consequences

Bought: one boundary, written in one place, correct for all six seeded-start paths — including the fork gap the persistence-layer version could not reach. The persistence packages keep a pure read path. firstLiveSeq gains a durable twin rather than a second, competing notion of the same boundary.

Cost: a seeded session's log is one event longer, including an empty resumed log. Seq expectations move with that boundary. Two updates are load-bearing rather than mechanical: telemetry's adoption tests assert the boundary IS exported, because it is this lifecycle's own write, and the property suite's replay invariant is "seed reproduced verbatim, plus one log-only boundary" with idempotence as its own property.

session/end-seed joins the on-disk vocabulary. Under the pre-release stance (SESSION_FORMAT_VERSION pinned at 0, no compatibility promise) older logs simply lack it, and a log without a boundary correctly classifies nothing as constructor-seed history.

The queued manual compaction decision now supplies the first consumer. Its tail scan independently finds the unmatched compaction/start and newest end-seed, treats only a start after that boundary as live, and clears the invariant trace on the same replay transition. The predicate remains in the compaction package rather than becoming a generic core helper.

中文

问题

在会话日志中拥有独立开/闭括号的插件无法区分一个已死的标记和一个存活的标记。compaction/startcompaction/end 就是已发布的实例:当接手一份日志、而它最后的压缩(compaction)事件是一个未配对的 compaction/start 时,「上一个写入方在压缩中途死掉了」与「此刻正有一次压缩在运行」在存储历史中是逐字节相同的。该括号所有方只能二选一:拒绝压缩一份其实空闲的日志(把会话卡死),或者在一份确实繁忙的日志上继续压缩。

日志中没有任何东西标出继承历史在哪里结束。session/createdsession/disposedsession/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.historysession.fork 源端路径不会在源会话中创建这条边界。

持久化无需任何改动

构造函数中的 append 发生在 enter() 之前,因此会话尚无 store attachment:该标记不会在 session/event 上发布,与它之前的种子事件完全一样。它属于 initFor 捕获的那份创建种子,并通过普通的种子路径落盘——onCreatedcreateCore + 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 视为存活,并在同一个回放转换上清除不变量追踪状态。该谓词仍位于压缩功能所在的包中,不会成为通用核心辅助函数。