Every session event now lives inside a turn (between turn/start and its turn/end). The loop records queued user/message events AFTER turn/start; an idle agent.inject() wraps its context/message in a one-shot injection turn. This makes the turn the single durability/replay boundary so a persistence backend can treat anything after the last turn/end as a crash tail without dropping legitimate between-turn context. A failure once the turn is already closed (rejecting session/flush, a throwing agent/turn-end listener) has no in-turn position for a session error event, so it is reported via agent/error + logger only; the turn stays balanced. failTurn appends an error event only while the turn is open. The dsh-invariants plugin enforces turn-enclosure via a default case: every non-boundary event type — including plugin-added merge-extensible keys — must sit inside an open turn or it throws. Documented in ADR 0017 + architecture.md.
5.4 KiB
ADR 0017: Every session event is enclosed in a turn
Status: accepted (2026-06-15)
Context
A durable session-persistence backend (added in a companion change) uses the turn as its crash-recovery boundary: load returns events only up to the last complete turn/end, and the first post-load append truncates whatever follows as a never-committed crash tail. This is safe only if nothing legitimately durable can sit after the last turn/end.
That assumption did not hold. Two paths recorded events outside any turn:
- Queued user messages. The loop drained queued messages and appended
user/messagebeforeturn/start— so a turn's own prompt sat in the gap between the previousturn/endand the nextturn/start. - Idle context injection.
agent.inject()appends acontext/messagedirectly. Its real production caller isdsh-tool-bash, which injects a background-task completion notice fromctx.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.
Two ways to fix it: relax the reader (let scanLog commit events that sit outside an open turn), or constrain the producer (make every event turn-enclosed so the reader's simple "last turn/end" rule is both correct and complete). We chose the producer-side invariant: a single, checkable rule beats a more permissive boundary scan that has to reason about partial turns and loose between-turn events.
Decision
Every session event lives inside a turn — between a turn/start and its matching turn/end. Concretely:
- The loop appends queued
user/messageevents afterturn/start(inside the turn), not before it.turn/endis therefore owed the moment those messages are recorded, and the existing finalizer guarantees it. - An
agent.inject()made while the agent is running appends itscontext/messageinto the already-open turn (unchanged). - An
agent.inject()made while idle wraps itscontext/messagein a one-shot turn:turn/start{trigger:{kind:'injection'}}→context/message→turn/end{completed}. A newinjectionvariant joins the merge-extensibleTurnTriggerMap. - 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-invariantsplugin enforces the invariant in dev: auser/message/context/message/steering/messageappended while no turn is open throws anInvariantError.
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.
Consequences
The turn is now the single durability/replay boundary, so a persistence backend's "last turn/end = commit point" rule is complete, not merely sufficient: a backend can discard everything after the last turn/end with zero risk of losing between-turn context, because there is no between-turn context. scanLog stays simple (no partial-turn boundary walk), 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.
The invariant also constrains where the loop may record an error event. A failure detected while a turn is open is appended INSIDE the turn (before turn/end); but a failure that surfaces once the turn is already closed — a rejecting session/flush (which runs as the post-turn/end durability checkpoint) or a throwing agent/turn-end listener (after closeTurn already appended turn/end) — has no in-turn position left. Appending an error there would land it past the last turn/end, exactly the crash-tail position a backend discards. So those post-turn failures are reported via the agent/error event and the logger only, never as a SessionEvent; the turn stays balanced and persistence keeps its buffered events for the next checkpoint. If durable operational diagnostics are ever needed, they belong on a separate telemetry channel, not the replayable session log.