refactor(session): remove synthetic log-only turns

This commit is contained in:
_Kerman
2026-07-28 14:41:51 +08:00
parent 4c6fb8b957
commit cee0666a4d
83 changed files with 355 additions and 819 deletions

View File

@@ -13,7 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { Message } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts'
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import { snapshotJsonValue } from './json.ts'
import { SurfaceManager } from './surface.ts'
import type { SessionSurface } from './surface.ts'
@@ -30,8 +30,8 @@ export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
/**
* Find the latest closed message-triggered turn, excluding injection and
* plugin-owned zero-step turns.
* Find the latest closed message-triggered turn, ignoring other triggers and
* between-turn events.
* @param events - session events, or an owned suffix, to inspect.
* @returns the latest matching turn end, or `undefined`.
*/
@@ -257,7 +257,6 @@ interface SessionEntry {
announced: boolean
announcing: boolean
appending: boolean
outOfBand: boolean
detachRequested: boolean
detach(): void
}
@@ -446,7 +445,7 @@ export class Session {
} finally {
if (entry !== undefined) {
entry.appending = false
if (entry.detachRequested && !entry.announcing && !entry.outOfBand) entry.detach()
if (entry.detachRequested && !entry.announcing) entry.detach()
}
}
}
@@ -587,8 +586,8 @@ export type SessionForkSource = Session | SessionId
* live store (`SESSION_NOT_FOUND`) or names a session object that is not the
* store's live instance (`SESSION_NOT_LIVE`); the requested child id is
* already taken (`SESSION_ALREADY_EXISTS`); the boundary is not a contiguous
* existing seq (`INVALID_BOUNDARY`); or the boundary event is not a
* `turn/end` — a fork must cut on a closed turn (`OPEN_TURN`).
* existing seq (`INVALID_BOUNDARY`); or the selected prefix ends inside an
* open turn (`OPEN_TURN`).
*/
export type SessionForkErrorCode =
| 'SESSION_NOT_FOUND'
@@ -729,7 +728,6 @@ export class SessionStore extends Service {
announced: false,
announcing: false,
appending: false,
outOfBand: false,
detachRequested: false,
detach: () => { this.detachEntered(entry) },
}
@@ -742,7 +740,7 @@ export class SessionStore extends Service {
// A lifecycle listener may own the advanced detach capability. Keep the
// entry and its publication hooks live until synchronous creation or append
// publication unwinds, then publish the paired disposal edge.
if (entry.announcing || entry.appending || entry.outOfBand) {
if (entry.announcing || entry.appending) {
entry.detachRequested = true
return
}
@@ -796,7 +794,7 @@ export class SessionStore extends Service {
}
} finally {
entry.announcing = false
if (entry.detachRequested && !entry.appending && !entry.outOfBand) entry.detach()
if (entry.detachRequested && !entry.appending) entry.detach()
}
}
@@ -840,87 +838,6 @@ export class SessionStore extends Service {
if (failure !== undefined) throw failure.reason
}
/**
* Append one plugin-declared log-only event without borrowing the agent
* loop's lifecycle. An open turn receives the event directly and remains
* responsible for its ordinary checkpoint. A closed log receives one
* zero-step turn around the event, followed by an awaited flush.
*
* Once the synthetic `turn/start` commits, this method always attempts its
* matching `turn/end` and flush, including when the target append fails.
* Detachment requested by an event or flush listener is deferred until that
* sequence settles, so publication cannot switch from a live scoped session
* to an unobserved bare `Session` halfway through the update.
*
* @param session - exact live session that owns the target log.
* @param type - event type opted into {@link OutOfBandSessionEventMap} by its owner.
* @param data - typed JSON payload for the target event.
* @param trigger - plugin-owned turn trigger used only when the log is closed.
* @returns the accepted target event with its assigned sequence and timestamp.
* @throws when the session is detached, another out-of-band append is active,
* event acceptance fails, the synthetic turn cannot close, or flushing fails.
*/
async appendOutOfBand<T extends OutOfBandSessionEventType>(
session: Session,
type: T,
data: SessionEventMap[T],
trigger: TurnTrigger,
): Promise<SessionEvent<T>> {
const entry = this.liveEntryFor(session)
if (entry.outOfBand) {
throw new Error(`session "${session.id}" already has an out-of-band append in progress`)
}
entry.outOfBand = true
// `T` is excluded from SurfaceEventType by OutOfBandSessionEventType, but
// TypeScript does not reduce Session.append's conditional rest parameter
// through a generic intersection. Preserve that proven two-argument call
// shape without widening the public Session.append overload.
const appendLogOnly = session.append.bind(session) as unknown as <K extends OutOfBandSessionEventType>(
eventType: K,
eventData: SessionEventMap[K],
) => SessionEvent<K>
try {
const lastBoundary = session.events.findLast(event => event.type === 'turn/start' || event.type === 'turn/end')
if (lastBoundary?.type === 'turn/start') {
return appendLogOnly(type, data)
}
const lastStart = session.events.findLast(event => event.type === 'turn/start')
const turn = (lastStart?.data.turn ?? 0) + 1
let accepted: SessionEvent<T> | undefined
let failure: unknown
let opened = false
try {
session.append('turn/start', { turn, trigger })
opened = true
accepted = appendLogOnly(type, data)
} catch (error: unknown) {
failure = error
} finally {
if (opened) {
// The only target types admitted by OutOfBandSessionEventMap are
// log-only plugin events, so the synthetic turn remains open here.
session.append('turn/end', { turn, reason: { kind: 'completed' } })
try {
await this.flush(session)
} catch (error: unknown) {
if (failure === undefined) failure = error
}
}
}
if (failure !== undefined) {
// eslint-disable-next-line @typescript-eslint/only-throw-error -- preserve an arbitrary flush-listener rejection exactly
throw failure
}
/* v8 ignore next -- accepted is assigned unless an append failure was captured above. */
if (accepted === undefined) throw new Error('out-of-band append completed without an accepted event')
return accepted
} finally {
entry.outOfBand = false
if (entry.detachRequested && !entry.announcing && !entry.appending) entry.detach()
}
}
/** Return the exact live entry; detached/prepared objects reject. */
private liveEntryFor(session: Session): SessionEntry {
const entry = attachments.get(session)
@@ -948,9 +865,10 @@ export class SessionStore extends Service {
}
/**
* Create a live child session from a turn-enclosed prefix of a live source.
* Create a live child session from a stable prefix of a live source.
* `boundary` is an inclusive source event seq; omitted means the source's
* current last event. A non-empty selected slice must end at `turn/end`.
* current last event. The selected slice may end with a between-turn event
* but must not end inside an open turn.
*
* @param source - Live source session object or id.
* @param boundary - Inclusive source event seq to fork through; omitted means
@@ -1007,9 +925,11 @@ export class SessionStore extends Service {
'INVALID_BOUNDARY',
)
}
if (boundaryEvent.type !== 'turn/end') {
const lastTurnBoundary = events.slice(0, boundary + 1)
.findLast(event => event.type === 'turn/start' || event.type === 'turn/end')
if (lastTurnBoundary?.type === 'turn/start') {
throw new SessionForkError(
`fork boundary ${boundary} in session "${session.id}" must be turn/end, got ${boundaryEvent.type}`,
`fork boundary ${boundary} in session "${session.id}" ends inside open turn ${lastTurnBoundary.data.turn}`,
'OPEN_TURN',
)
}

View File

@@ -66,8 +66,8 @@ function validateEvent(
let nextStep = trace.nextStep
let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' }
// Model input may be appended between turns without running the model.
// Merge-extensible package events remain turn-enclosed by default.
// Context and plugin-owned log-only events may be appended between model
// executions. Core execution events retain their explicit turn relations.
switch (event.type) {
case 'turn/start': {
if (trace.openTurn !== null) {
@@ -143,12 +143,17 @@ function validateEvent(
}
case 'user/message':
break
default: {
case 'steering/message':
case 'todo/write':
case 'request/header': {
if (trace.openTurn === null) {
fail(`${event.type} appended outside any open turn (every event must be turn-enclosed)`)
fail(`${event.type} appended outside any open turn (core execution events must be turn-enclosed)`)
}
break
}
default:
// Merge-extensible event relations belong to their owning plugin.
break
}
return {
scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep },

View File

@@ -257,23 +257,9 @@ export interface SessionEventMap {
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
}
/**
* Marker map for plugin-owned log-only events accepted by
* `SessionStore.appendOutOfBand()`. A plugin extends this map with the same key
* it adds to {@link SessionEventMap}; surface and lifecycle events stay
* ineligible unless their owner explicitly opts them into this narrow seam.
*/
export interface OutOfBandSessionEventMap {}
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
export type SessionEventType = keyof SessionEventMap
/** Plugin-declared non-surface event types accepted by `SessionStore.appendOutOfBand()`. */
export type OutOfBandSessionEventType = Exclude<
Extract<SessionEventType, keyof OutOfBandSessionEventMap>,
SurfaceEventType
>
/**
* The subset of {@link SessionEventType} values whose events produce LLM
* messages and are eligible to appear on the ordered surface. Only these