DSH / Atlas
2026-07-28implementedsimplification

Remove synthetic turns for log-only events

移除纯日志事件的合成轮次

The session store exposed `appendOutOfBand()` so a plugin could publish a late log-only event while no agent turn was running. The method wrapped that event in `turn/start` and `turn/end`, then flushed it. This preserved the old rule that every durable event had to live inside a turn, but it made one identifier mean both a model-loop execution and a persistence-only update. That rule was introduced when persistence r

English

Problem

The session store exposed appendOutOfBand() so a plugin could publish a late log-only event while no agent turn was running. The method wrapped that event in turn/start and turn/end, then flushed it. This preserved the old rule that every durable event had to live inside a turn, but it made one identifier mean both a model-loop execution and a persistence-only update.

That rule was introduced when persistence recovery treated the last turn/end as the only committed boundary. The persistence scanners now preserve every valid contiguous event, and crash repair reacts only to an actually open turn. Retaining synthetic turns for title updates therefore inflated turn counts, produced execution outcomes for work that never ran the model, and let a late metadata write consume the next turn number.

The generic helper also duplicated domain policy. Its marker map said which plugin events were eligible, while the title capability already owned cancellation, liveness, and stale-result rules. Replacing it with another generic or title-specific append wrapper would preserve the same type indirection for two literal event types.

Decision

SessionStore.appendOutOfBand(), OutOfBandSessionEventMap, and OutOfBandSessionEventType do not exist. A plugin that owns a log-only event appends it through Session; when the operation promises durability, it explicitly awaits ctx.sessions.flush(session). No turn is opened solely to obtain that checkpoint.

Core session invariants continue to enforce core-owned execution relations: turn and step numbering, enclosure of steering, assistant, tool, todo, and request-header events, and same-step tool call/result pairing. Core permits merge-extensible events between turns because only their declaring plugin knows whether they are execution-scoped or standalone. Plugin invariant companions remain responsible for their own event relations.

The title service appends session/title directly after its existing service, revision, cancellation, and live-session checks. The bundled model helper appends its literal session/title-llm-request record before dispatch. Persistence admits both through the bounded session/event path and drains them at ordinary checkpoints and lifecycle teardown; neither append forces a flush merely because it is between turns. A fallback, auxiliary request record, or accepted provider title may therefore appear after turn/end and before the next turn/start. Manual compaction uses the same between-turn capability for a compaction/* { turn: null } bracket, but explicitly flushes the closed attempt because /compact promises durability before releasing queued prompt admission.

A session fork may end at any stable event position outside an open turn, not only at turn/end. This preserves standalone title and other plugin-owned log-only records in a default fork while still rejecting a prefix cut through active execution.

The historical universal turn-enclosure decision remains useful only as the reason the synthetic mechanism was introduced. The context-injection decision established the current meaning: one turn represents one model-loop execution. The queued manual compaction decision applies that rule to a durable multi-event bracket and owns its marker and admission semantics.

Alternatives considered

Keep synthetic zero-step turns. This preserves a uniform-looking log and reuses turn/end as a flush point, but it reports executions that never happened, perturbs turn numbering, and makes every turn consumer filter persistence-only records. Durability already has the independent session/flush boundary.

Keep a generic core durable-append helper without synthetic turns. A method that performs append() plus flush() is small, but its eligibility marker and concurrency promises would still centralize plugin policy in the session store. The event owners already have literal typed append sites, and a caller that truly needs a durability barrier can await the existing session/flush operation at that boundary.

Store titles as mutable session metadata. This avoids between-turn events but creates a second mutation, replay, persistence, and fork protocol beside the append-only log. Titles remain replayable latest-wins events instead.

Require every plugin event to declare standalone eligibility to core. This keeps a central allowlist but makes absence mean an execution relation that core cannot verify. Merge-extensible unions already assign semantic ownership to the declaring plugin; its invariant companion is the correct enforcement point.

Verification

Core invariant tests accept an unknown plugin event between turns while continuing to reject built-in execution events there. Hook, plan-mode, Code Mode dispatch, and approval invariant companions reject their execution-scoped events when no turn is open; the compaction companion separately accepts a balanced turn: null manual bracket between turns and requires numeric owners to match an open turn. Session-title service tests pin one direct fallback event under concurrent refresh, detached-session rejection, and newest-revision acceptance. JSONL and SQLite round trips preserve a title appended after turn/end through the persistence lifecycle drain, and fork tests retain a standalone log-only tail while rejecting boundaries inside an open turn. A keyless assembled ACP snapshot delays the model-backed title until after turn/end and pins one standalone provider title with no synthetic turn. Generated API and type-equivalence catalogs contain no removed symbol.

Consequences

Turn counts and outcomes again describe model-loop executions only. Standalone events and manual compaction brackets consume session seqs without consuming a turn number, enter bounded persistence like every other append, and require owners to request an explicit durability barrier only when their operation promises one. Generic plugin mistakes no longer fail under a core default enclosure rule, so each plugin that needs an execution relation must state and test that relation itself. The title capability keeps revision ordering and lifecycle persistence with less core state, and manual compaction gains durable control with no synthetic-turn or turn-number collision.

中文

问题

会话存储曾暴露 appendOutOfBand(),让插件可以在没有 agent(智能体)轮次运行时发布延迟到达的纯日志事件。该方法会用 turn/startturn/end 包住事件,再将其刷写。这保留了「每个持久事件都必须位于轮次内」的旧规则,却让同一个标识符既表示模型循环执行,又表示仅持久化更新。

引入该规则时,持久化恢复曾将最后一个 turn/end 视为唯一的已提交边界。如今,持久化扫描器会保留每个合法且连续的事件,崩溃修复也只处理确实处于开放状态的轮次。因此,为标题更新保留合成轮次会夸大轮次计数、为从未运行模型的工作产生执行结果,还会让延迟到达的元数据写入占用下一个轮次编号。

通用辅助函数还重复了领域策略。它的标记映射说明哪些插件事件符合条件,而标题能力本就拥有取消、活跃性和陈旧结果处理规则。改用另一个通用或标题专属追加包装层,仍会为两个字面量事件类型保留同一层类型间接性。

决策

SessionStore.appendOutOfBand()OutOfBandSessionEventMapOutOfBandSessionEventType 均不再存在。拥有纯日志事件的插件通过 Session 追加该事件;当操作承诺持久性时,插件会显式等待 ctx.sessions.flush(session)。系统不会仅为获得该检查点而打开轮次。

核心会话不变量继续强制核心所属的执行关系:轮次与步骤编号、steering(中途引导)、助手、工具、待办和请求头事件的封闭,以及同一步骤内的工具调用/结果配对。核心允许可合并扩展事件位于轮次之间,因为只有声明它们的插件知道这些事件受执行作用域约束,还是可以独立存在。插件的不变量配套组件仍负责其自身的事件关系。

标题服务会在完成既有的服务状态、修订、取消和活跃会话检查后,直接追加 session/title。随附模型辅助函数会在发起调用前追加其字面量 session/title-llm-request 记录。持久化通过有界 session/event 路径接纳两者,并在常规检查点与生命周期结束时排空;二者都不会仅因为位于轮次之间就强制刷写。因此,回退标题、辅助请求记录或已接受的提供方标题可以出现在 turn/end 之后、下一个 turn/start 之前。手动压缩(compaction)利用同一项轮次间能力记录 compaction/* { turn: null } 标记对,但会显式刷写已闭合的尝试,因为 /compact 承诺在放行排队中的提示词前完成持久化。

会话 fork 可以结束于开放轮次之外的任意稳定事件位置,而不限于 turn/end。这样,默认 fork 会保留独立标题和其他插件所属的纯日志记录,同时仍拒绝在活跃执行过程中截断前缀。

历史上的通用轮次封闭决策如今只适合用于解释为何曾引入合成机制。上下文注入决策确立了当前语义:一个轮次表示一次模型循环执行。排队手动压缩决策将该规则应用于持久多事件标记对,并拥有其标记与接纳语义。

曾考虑的替代方案

保留合成的零步骤轮次。 这可以让日志在形式上保持统一,并复用 turn/end 作为刷写点,但会报告从未发生的执行、扰动轮次编号,还会迫使每个轮次消费方过滤仅持久化记录。持久性已有独立的 session/flush 边界。

保留不使用合成轮次的通用核心持久追加辅助函数。 执行 append() 再执行 flush() 的方法本身很小,但其准入标记和并发承诺仍会将插件策略集中到会话存储中。事件所有方已经拥有直接、类型化的字面量追加点;真正需要持久性屏障的调用方可以在该边界等待既有的 session/flush 操作。

将标题存储为可变会话元数据。 这可以避免轮次间事件,却会在仅追加日志之外建立第二套变更、回放、持久化和 fork 协议。标题仍采用可回放、后写覆盖的事件。

要求每个插件事件向核心声明可独立存在的资格。 这可以保留中心准入列表,但会让缺少声明意味着一种核心无法验证的执行关系。可合并扩展联合类型已经将语义所有权赋予声明事件的插件;该插件的不变量配套组件才是正确的强制位置。

验证

核心不变量测试会接受轮次之间的未知插件事件,同时继续拒绝位于该处的内置执行事件。钩子、plan-mode、Code Mode 分发和审批的不变量配套组件会在没有开放轮次时拒绝其执行作用域事件;压缩配套组件则另外接受轮次之间平衡的 turn: null 手动标记对,并要求数字 owner匹配一个开放轮次。会话标题服务测试会在并发刷新、拒绝已脱离会话和接受最新修订的场景下,固定一个直接追加的回退事件。JSONL 和 SQLite 往返测试会通过持久化生命周期排空保留追加在 turn/end 之后的标题;fork 测试会保留独立纯日志尾部,同时拒绝位于开放轮次内的边界。一个无密钥、经完整组装的 ACP(Agent Client Protocol)快照会将模型生成的标题延迟到 turn/end 之后,并固定一个不含合成轮次的独立提供方标题。生成的 API 和类型等价性目录不含任何已移除符号。

后果

轮次计数和结果重新只描述模型循环执行。独立事件和手动压缩标记对会占用会话 seq,但不占用轮次编号;它们像其他追加一样进入有界持久化,并且仅当操作承诺持久性时,才要求事件所有方请求显式持久性屏障。通用插件错误不再因核心默认的封闭规则而失败,因此每个需要执行关系的插件都必须自行声明并测试该关系。标题能力保留修订排序和生命周期持久化,同时减少了核心状态;手动压缩则获得持久控制,不产生合成轮次或轮次编号冲突。