DSH / Atlas
2026-06-15implementedarchitecturearchived 2026-07-28

Every session event is enclosed in a turn

每个会话事件都封闭在一个轮次内

A durable session-persistence backend (added in a companion change) uses the **turn** as its crash-recovery boundary: a crash can leave an unclosed final turn, which `load` closes with a synthetic `turn/end {kind:'interrupted'}` while preserving the turn's real events (see [session persistence](2026-06-14-session-persistence.md)). This recovery is only well-defined if nothing *legitimately* durable sits OUTSIDE a tur

English

Problem

A durable session-persistence backend (added in a companion change) uses the turn as its crash-recovery boundary: a crash can leave an unclosed final turn, which load closes with a synthetic turn/end {kind:'interrupted'} while preserving the turn's real events (see session persistence). This recovery is only well-defined if nothing legitimately durable sits OUTSIDE a turn — between the last turn/end and the next turn/start — since such an event would be swept into the next turn's interrupted close.

That assumption did not hold. Two paths recorded events outside any turn:

  1. Queued user messages. The loop drained queued messages and appended user/message before turn/start — so a turn's own prompt sat in the gap between the previous turn/end and the next turn/start.
  2. Idle context injection. agent.inject() appends a context/message directly. Its real production caller is dsh-tool-bash, which injects a background-task completion notice from ctx.bash.onTaskDone — a callback that fires whenever a background bash task finishes, frequently while the agent is idle (between turns).

In case 2, if the injected context/message is the last event before a flush/dispose (no later turn appends a turn/end), scanLog treats it as crash debris and drops it on resume — the injected context is durably on disk but silently lost on reload. Case 1 was benign in isolation (a user/message is always followed by the turn it triggered) but made the "what may appear outside a turn" rule fuzzy.

Decision

Every session event lives inside a turn — between a turn/start and its matching turn/end. Concretely:

  • The loop appends queued user/message events after turn/start (inside the turn), not before it. turn/end is therefore owed the moment those messages are recorded, and the existing finalizer guarantees it.
  • An agent.inject() made while the agent is running joins the already-open turn. While the current step executes assistant tool calls, accepted context waits in arrival order until that batch settles, then appends after every recorded result and before the turn closes even when execution is interrupted.
  • An agent.inject() made while idle wraps its context/message in a one-shot turn: turn/start{trigger:{kind:'injection'}}context/messageturn/end{completed}. A new injection variant joins the merge-extensible TurnTriggerMap.
  • The loop derives the next turn number from the log each iteration (lastTurnNumber(session) + 1) instead of keeping a private counter, so an idle injection's one-shot turn cannot collide with the next real turn's number.
  • The dsh-session/invariant companion registers the check with ctx.invariants: when selected, a user/message / context/message / steering/message appended while no turn is open throws an InvariantError attributed to @deepseek-ai/dsh-session.

The serializability invariant is enforced at the same source boundary (Session.append throws on non-JSON-serializable data), so "what may enter the log" is now governed in one place rather than discovered downstream by whichever backend happens to be watching.

Alternatives considered

Relax the reader instead of constraining the producer — let scanLog commit events that sit outside an open turn. Rejected: a single, checkable producer-side rule beats a more permissive boundary scan that has to reason about partial turns and loose between-turn events.

Consequences

The turn is now the single durability/replay boundary, so session persistence's crash-recovery rule is complete, not merely sufficient: an interrupted final turn is closed (with a synthetic turn/end {interrupted}) and its real events preserved, with zero risk of conflating between-turn context into it, because there is no between-turn context. scanLog stays simple (one possibly-open final turn, never a loose between-turn event), and an idle background-task notice survives persist + resume.

Costs: agent.inject() while idle now writes three log lines instead of one, and the derived history gains a turn that carries only injected context (no assistant output) — deriveMessages() already derives purely by event type, so this renders identically. The injection trigger is a new on-disk vocabulary value; like every SessionEventMap/TurnTriggerMap addition it is part of the frozen format. Event ordering within a turn changed (turn/start now precedes user/message), which is observable to anything that asserted the old order — the loop's own tests were the only such consumers.

The rule is intentionally producer-enforced and dev-checked rather than reader-tolerated: a future backend (SQLite/WAL) inherits the same clean boundary for free, and a plugin that records an event outside a turn fails loudly in dev instead of silently losing data on the next reload.

Failures detected during a turn are logged before turn/end. A later flush failure has no valid in-turn position, so it is reported through agent/error and logging rather than appended as a session event. This preserves a balanced replay log; durable operational diagnostics require a separate telemetry channel.

中文

问题

持久化的会话持久化后端(在配套变更中引入)以轮次作为崩溃恢复边界:崩溃可能留下一个未关闭的最终轮次,load 会用一个合成的 turn/end {kind:'interrupted'} 将其关闭,同时保留该轮次的真实事件(见会话持久化)。这种恢复只有在没有任何合法的持久事件位于轮次之外(即上一个 turn/end 与下一个 turn/start 之间的间隙)时才是良定义的,否则这类事件会被卷入下一个轮次的中断关闭中。

这一假设并不成立。有两条路径在任何轮次之外记录了事件:

  1. 排队的用户消息。 agent loop(智能体循环)排空排队消息并在 turn/start 之前追加 user/message——于是一个轮次自身的提示词落在了前一个 turn/end 与下一个 turn/start 之间的间隙中。
  2. 空闲时的上下文注入。 agent.inject() 直接追加一条 context/message。它在生产环境中的真实调用方是 dsh-tool-bash,后者从 ctx.bash.onTaskDone 注入后台任务完成通知——该回调在后台 bash 任务完成时触发,而这经常发生在 agent 空闲(轮次之间)时。

在情况 2 中,如果注入的 context/message 是 flush/dispose 之前的最后一个事件(之后没有轮次追加 turn/end),scanLog 会将其视为崩溃残留并在恢复时丢弃——注入的上下文已持久写入磁盘,但重新加载后被静默丢失。情况 1 本身无害(user/message 之后总会跟着它触发的轮次),但使「什么可以出现在轮次之外」这条规则变得模糊。

决策

每个会话事件都位于一个轮次内部:在 turn/start 与其匹配的 turn/end 之间。具体而言:

  • agent loop 在 turn/start 之后(轮次内部)追加排队的 user/message 事件,而非之前。因此,一旦这些消息被记录,就欠下一个 turn/end,既有的 finalizer 保证它被写入。
  • agent 运行中调用 agent.inject() 时,它会加入已打开的轮次。当前步骤执行 assistant 工具调用期间,已接受的上下文按到达顺序等待该批次结算,随后在每个已记录结果之后追加;即使执行中断,也会在轮次关闭前写入。
  • agent 空闲时调用 agent.inject(),则将 context/message 包裹在一个一次性轮次中:turn/start{trigger:{kind:'injection'}}context/messageturn/end{completed}。一个新的 injection 变体加入可合并扩展的 TurnTriggerMap
  • agent loop 每次迭代从日志推导下一个轮次编号(lastTurnNumber(session) + 1),而不是维护一个私有计数器,这样空闲注入的一次性轮次不会与下一个真实轮次的编号冲突。
  • dsh-session/invariant companion 将该检查注册到 ctx.invariants:选中后,在没有打开轮次的情况下追加 user/message / context/message / steering/message 会抛出归因于 @deepseek-ai/dsh-sessionInvariantError

可序列化性不变式在同一源码边界处强制执行(Session.append 对不可 JSON 序列化的数据抛出异常),因此「什么可以进入日志」现在由一个位置统一管控,而非由下游碰巧在监听的某个后端各自发现。

曾考虑的替代方案

放宽读取端而非约束生产端——让 scanLog 提交位于已打开轮次之外的事件。否决:一条单一、可检查的生产端规则优于一个更宽松的边界扫描(后者需要同时推理部分轮次轮次间的散落事件)。

后果

轮次现在是唯一的持久性/回放边界,因此会话持久化的崩溃恢复规则是完备的,而不仅仅是充分的:被中断的最终轮次被关闭(用合成的 turn/end {interrupted}),其真实事件得以保留,且零风险将轮次间上下文混入其中,因为不存在轮次间上下文。scanLog 保持简洁(最多一个可能未关闭的最终轮次,绝无散落的轮次间事件),空闲时的后台任务通知在持久化 + 恢复后依然存活。

代价:空闲时调用 agent.inject() 现在写入三行日志而非一行;派生的历史中多出一个仅包含注入上下文(无 assistant 输出)的轮次——deriveMessages() 已经纯粹按事件类型派生,因此渲染结果完全相同。injection 触发器是一个新的磁盘词汇值;与每次 SessionEventMap/TurnTriggerMap 的新增一样,它属于冻结格式的一部分。轮次内的事件顺序发生了变化(turn/start 现在先于 user/message),这对任何断言旧顺序的代码可观测——agent loop 自身的测试是唯一的此类消费方。

该规则有意采用生产端强制、开发环境检查的方式,而非读取端容忍的方式:未来的后端(SQLite/WAL)无需额外工作即可继承同样干净的边界,而在轮次外记录事件的插件会在开发环境中大声失败,而非在下次重新加载时静默丢失数据。

轮次内检测到的失败在 turn/end 之前记录。后续的 flush 失败没有有效的轮次内位置,因此通过 agent/error 和日志报告,而非作为会话事件追加。这保持了回放日志的平衡;持久化的运维诊断需要一个独立的遥测通道。