feat(agent-loop): turn-enclosure invariant + post-turn error model

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.
This commit is contained in:
Tianyi Cui
2026-06-15 20:56:17 +08:00
parent 0731ed374b
commit b0bc0b5792
13 changed files with 386 additions and 49 deletions
+20 -5
View File
@@ -105,11 +105,10 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
}
trace.lastSeq = event.seq
// Intentionally non-exhaustive: only events that carry ordering structure
// are checked; the rest are trace/replay data with no nesting contract.
// SessionEventMap is merge-extensible, so no assertNever — unknown event
// types fall through untouched.
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
// Boundary/step-scoped events have explicit cases; every OTHER event type —
// including plugin-added (merge-extensible) SessionEventMap keys — is caught
// by the `default` and must be turn-enclosed (ADR 0017). No assertNever: an
// unknown variant is valid, not a compile error.
switch (event.type) {
case 'turn/start': {
if (trace.openTurn !== null) {
@@ -169,6 +168,22 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
}
break
}
// Turn-enclosure (ADR 0017): EVERY session event not handled by a boundary
// case above must sit inside an open turn. The durable session log uses the
// turn as its commit/replay boundary (the JSONL backend treats anything
// after the last turn/end as a crash tail), so a bare event between turns is
// silently dropped on reload. The loop records queued user messages after
// turn/start, an idle agent.inject() wraps its context/message in a one-shot
// turn, and usage/error are only appended inside an open turn. A `default`
// (not an enumerated list) is deliberate: SessionEventMap is
// merge-extensible, so a PLUGIN-added event type appended while idle must
// also fail here rather than fall through and be dropped on resume.
default: {
if (trace.openTurn === null) {
throw new InvariantError(`${event.type} appended outside any open turn (every event must be turn-enclosed)`)
}
break
}
}
}