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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user