fix(plan-mode): flush pending mode only at the in-turn agent/step seam

Prompt admission runs before any turn opens, so the agent/prompt-submit
flush listener could only ever append plan/mode outside an open turn —
invariant-vetoed and retried at agent/step when the session invariant is
mounted, silently out-of-turn in the durable log when it is not. agent/step
fires inside the open turn before every request derivation (including
turn 1 step 1), so it is the sole flush point and no behavior is lost: a
pending idle switch lands as the first step's in-turn plan/mode. Tests pin
the new contract: admission never appends; the first step boundary flushes.
This commit is contained in:
_Kerman
2026-07-26 11:47:39 +08:00
parent 2154034a0b
commit 4284530c7e
2 changed files with 44 additions and 53 deletions
+7 -18
View File
@@ -157,24 +157,13 @@ export class PlanModeService extends Service {
this.section = resolveConfig(config).section
let disposed = false
// Boundary flushes use loop interception seams, not post-commit
// `session/event` observation. Flush after next(): a selection arriving
// while a downstream async listener awaits must still shape the request
// this boundary precedes. Failures are contained so policy cannot block a
// prompt or turn; a failed append remains pending for a later boundary.
const flushAfter = async <T>(agent: Agent, next: () => Promise<T>): Promise<T> => {
const decision = await next()
if (!disposed) {
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
}
}
return decision
}
ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) =>
flushAfter(agent, next), { prepend: true })
// The boundary flush uses the loop's `agent/step` interception seam, not
// post-commit `session/event` observation. `agent/step` runs inside the
// open turn before every request derivation (including turn 1 step 1), so
// it is the sole flush point: prompt admission happens pre-turn, where a
// `plan/mode` append would land outside any open turn. Failures are
// contained so policy cannot block a turn; a failed append remains
// pending for a later boundary.
ctx.on('agent/step', (agent) => {
if (disposed) return
try {