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
+47 -2
View File
@@ -12,7 +12,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session } from '@deepseek-ai/dsh-session'
import { Inbox } from './inbox.ts'
import { runLoop } from './loop.ts'
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
/**
* The concrete {@link Agent} implementation owned by the agent-loop plugin.
@@ -73,7 +73,52 @@ export class LoopAgent implements Agent {
inject(content: ContentBlock[], options?: SendOptions): void {
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
this.session.append('context/message', { content, source: this.resolveSource(options) })
const source = this.resolveSource(options)
if (isTurnOpen(this.session)) {
// A turn is open in the LOG (decided from the log, not agent status —
// status can be `running` with no turn open): the context/message is
// turn-enclosed by that turn, so append it directly.
this.session.append('context/message', { content, source })
return
}
// No turn open: wrap the injection in a one-shot turn so every event stays
// turn-enclosed (the durability/replay boundary is the turn).
const turn = lastTurnNumber(this.session) + 1
// Once turn/start enters the log, a turn/end is OWED no matter what — even
// if a throwing `session/event` listener escapes from the turn/start append
// (Session.append pushes the event BEFORE notifying listeners) or the
// context/message append throws (non-serializable content, throwing
// listener). The finally re-checks the log via isTurnOpen() and closes the
// turn if one was actually opened, so the log never carries a permanently
// open injection turn that would corrupt later turns/replay. (If the
// turn/start append throws BEFORE pushing — non-serializable trigger, which
// can't happen for our fixed trigger — no turn was opened and none is owed.)
let turnRecorded = false
try {
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
this.session.append('context/message', { content, source })
} finally {
// A turn was recorded iff turn/start made it into the log. Close it and
// mark it for the durability checkpoint below — which must run even when
// an append's listener threw (the turn is balanced and in memory, so it
// still needs a flush or a crash before the next turn/dispose loses it).
if (isTurnOpen(this.session)) {
this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
turnRecorded = true
}
// Checkpoint the one-shot turn for durability, exactly as the loop does at
// every turn/end. The loop is NOT running (we are idle), so nothing else
// will flush this turn. Fire-and-forget with error containment: inject()
// is synchronous, and a persistence backend failing must not throw into
// the caller (e.g. a tool-bash task-done callback). Disposal still drains
// independently, so a slow flush is safe. In the finally so it also runs
// when an append's listener threw (the turn is still balanced + durable).
if (turnRecorded) {
void Promise.resolve(this.ctx.parallel('session/flush', this.session)).catch((error: unknown) => {
this.ctx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${String(error)}`)
})
}
}
}
abort(reason?: string): void {