fix(session): contain post-commit observers

This commit is contained in:
Tianyi Cui
2026-07-12 18:57:42 +08:00
parent 50873b8bd0
commit e8fed4fb66
31 changed files with 1166 additions and 475 deletions

View File

@@ -9,31 +9,31 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
### Public API
- `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log: the constructor reads each array entry once, then recursively validates and copies every nested value in one pass so validation and storage cannot observe different getter results or erase an exotic prototype before checking it. `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`: the store rejects an exotic metadata shell, reads every accepted field once, and constructs a detached, deep-frozen header. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber.
- `ctx.sessions.flush(session: Session): Promise<void>` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier.
- `ctx.sessions.flush(session: Session): Promise<void>` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). Every captured listener starts, the call waits for all of them to settle, and a failure rejects only after the other listeners finish. Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier.
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
#### Advanced: ordered-teardown lifecycle primitives
`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before the store-owned append observer detaches`create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect:
`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before the store attachment and publication hooks are removed`create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect:
- `ctx.sessions.prepare(id?, options?): Session` — read `options.seed`/`options.meta` once, validate and detach the metadata/header, and construct the `Session` WITHOUT entering it into the store. Same options as `create`.
- `ctx.sessions.reserve(id): SessionRegistrationReservation` — hold an unpublished id under the calling fiber and construct its one owned Session through `reservation.prepare(options?)`. `release` is the exact owner effect disposer, so the agent lifecycle can adopt it and keep the ID reserved until scope cleanup quiesces. Until that release, bare `prepare`/`create`/`enter` calls for the id reject; the factory later presents the exact capability to `enter`, making setup-time publication structurally impossible without leaking an abandoned reservation across HMR disposal.
- `ctx.sessions.enter(session, reservation?): () => void` — claim the ID across caller-controlled filter/carrier evaluation, then install the module-private `session/event` observer and add the exact session under its accepted key; a reentrant same-ID entry cannot be overwritten. Returns the idempotent, exact-object-guarded DETACH disposer, which clears notification, carrier, and accepted-key state without letting a stale capability delete a replacement. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved. A factory passes the opaque capability from `reserve(id)` so setup cannot enter the reserved session or publish a same-id replacement before the owning transaction.
- `ctx.sessions.enter(session, reservation?): () => void` — claim the ID across caller-controlled filter/carrier evaluation, then install the module-private append publication hooks and add the exact session under its accepted key; a reentrant same-ID entry cannot be overwritten. Returns the idempotent, exact-object-guarded DETACH disposer, which clears publication, carrier, and accepted-key state without letting a stale capability delete a replacement. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved. A factory passes the opaque capability from `reserve(id)` so setup cannot enter the reserved session or publish a same-id replacement before the owning transaction.
- `ctx.sessions.announce(session): void` — begin the one allowed `session/created` announcement for an entered session; repeat and reentrant calls reject before dispatch. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, so another creation listener cannot observe `session/disposed` before its own `session/created` callback. Detach emits `session/disposed` exactly once, including rollback after a partially delivered creation notification; a never-announced entry emits neither edge.
`dsh-agent-loop` is the canonical consumer: after unpublished agent setup it enters both session and agent before announcing either, then nests loop stop, agent removal, session detach, and scope unwind in one ordered lifecycle. The final flush therefore settles before this package detaches the session, whether teardown starts from an `AgentHandle` or owner-fiber unload.
### Live service events
The store pairs announced creation with disposal, publishes each append, and provides an awaited durability checkpoint. Disposal listener failures, including returned-promise rejections, are contained per observer so teardown cannot be interrupted. Exact `session/*` signatures, modes, and scope-carrier behavior live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the append-only payload vocabulary is separately generated into the [persistence catalog](../../../docs/persistence-catalog.md). Persistence consumers write behind from the append notification and drain on the store-owned flush entry point rather than dispatching the event directly.
The store pairs announced creation with disposal, publishes each append, and provides an awaited durability checkpoint. Before the log push it resolves the exact scoped `session/event` callback list, including development-time internal dispatch checks; substitution of the accepted session/event tuple rejects while the log is unchanged. The push is then the commit point, and callback throws or returned-promise rejections are logged and contained per observer. A committed append therefore returns normally, later observers still run, and teardown cannot interrupt an in-flight acceptance/publication boundary. Exact `session/*` signatures, modes, and scope-carrier behavior live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the append-only payload vocabulary is separately generated into the [persistence catalog](../../../docs/persistence-catalog.md). Persistence consumers write behind from the append notification and drain on the store-owned flush entry point rather than dispatching the event directly.
### Class: `Session`
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` or surface metadata is not losslessly JSON-serializable (BigInt, function, symbol, undefined, `-0`, non-finite number, circular ref, or an exotic object like Map/Set/Date/class instance). One recursive validate-and-copy pass reads each nested value exactly once and produces the detached value that enters the log, so validation and durability cannot diverge through a stateful getter or a prototype-erasing clone. The accepted event and every nested value are deep-frozen before publication; the returned event and observer notification share that immutable owned record. A third parameter `opts: SurfaceIntent` carries surface metadata: `surfaceOp` and `sourceEventSeqs` are each read once, then the former controls how the event enters the surface linked list and the latter records provenance. Runtime validation accepts only `'append'` or the exact `{ op: 'replace', start, end }` record with non-negative safe-integer bounds, and provenance must be an array of non-negative safe integers; non-surface events reject either field. The marker is **required** for the five `SurfaceEventType` events (every message-producing event must declare how it joins the surface) and rejected by the compiler for non-surface types. The contract is enforced two ways: the typed overload handles a specific event literal, AND runtime checks cover widened unions and raw seed/load logs so invalid metadata can never silently enter or disappear from `deriveMessages()`.
- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` or surface metadata is not losslessly JSON-serializable (BigInt, function, symbol, undefined, `-0`, non-finite number, circular ref, or an exotic object like Map/Set/Date/class instance). One recursive validate-and-copy pass reads each nested value exactly once and produces the detached value that enters the log, so validation and durability cannot diverge through a stateful getter or a prototype-erasing clone. The accepted event and every nested value are deep-frozen before publication; the returned event and observer notification share that immutable owned record. An entered session pins its attachment from materialization through observer delivery, rejects if a caller getter changes that attachment, and rejects a reentrant append until the outer callback list drains; these rules prevent an event from bypassing persistence or being delivered out of log order. The log push is the commit point: a synchronous observer throw or returned-promise rejection is logged per observer and cannot turn the committed append into a caller-visible failure or starve later observers. A third parameter `opts: SurfaceIntent` carries surface metadata: `surfaceOp` and `sourceEventSeqs` are each read once, then the former controls how the event enters the surface linked list and the latter records provenance. Runtime validation accepts only `'append'` or the exact `{ op: 'replace', start, end }` record with non-negative safe-integer bounds, and provenance must be an array of non-negative safe integers; non-surface events reject either field. The marker is **required** for the five `SurfaceEventType` events (every message-producing event must declare how it joins the surface) and rejected by the compiler for non-surface types. The contract is enforced two ways: the typed overload handles a specific event literal, AND runtime checks cover widened unions and raw seed/load logs so invalid metadata can never silently enter or disappear from `deriveMessages()`.
- `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array snapshot per call over SHARED, deep-frozen `Message` objects — cloned once off the log at projection time, so a consumer can never mutate logged data (mutation throws). The surface is the single source of derived history — there is no raw-log fallback.
- `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: one event's derived message (an unfrozen clone), or `null` when it produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC).
- `session.surface: SurfaceManager` — the derived surface, lazily rebuilt from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. `surface.replaceGeneration` is the rewrite signal: bumped by every folded `replace` and by `invalidate()`, never reset, so an incremental consumer comparing generations cannot be fooled.

View File

@@ -64,7 +64,12 @@ declare module 'cordis' {
'session/disposed'(this: Scoped<Session>, session: Session): void
/**
* An event was appended to a session log (sync, fire-and-forget). This is
* the per-append feed a UI or invariant plugin tails.
* the per-append feed a UI or invariant plugin tails. The log push is the
* commit point; synchronous throws and returned-promise rejections from
* observers are logged and contained per listener, so they cannot make a
* committed append appear to fail or starve later listeners. The exact
* callback list and Cordis internal-dispatch checks resolve before the push;
* callbacks themselves run only after it.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
* session's owner scope, captured when the session was ENTERED (an agent's
* session is entered through `agent.ctx`, so its events dispatch in that
@@ -279,7 +284,62 @@ function renderThrown(value: unknown): string {
}
}
const appendObservers = new WeakMap<Session, (event: SessionEvent) => void>()
/** Best-effort reporting that cannot re-expose an already-contained failure. */
function warnContained(ctx: Context, message: string): void {
try {
ctx.logger.warn(message)
} catch {
// contained: logger failure must not turn an observe-only callback failure
// back into a caller-visible error or an unhandled promise rejection.
}
}
type SessionCallback = (...args: unknown[]) => unknown
/** Resolve one listener snapshot, including Cordis's internal dispatch checks. */
function collectSessionCallbacks(ctx: Context, args: unknown[]): SessionCallback[] {
return [...ctx.events.dispatch('emit', args)] as SessionCallback[]
}
/** Reject pre-commit dispatch instrumentation that substituted accepted values. */
function assertDispatchTuple(name: string, actual: unknown[], expected: unknown[]): void {
if (actual.length !== expected.length || actual.some((value, index) => value !== expected[index])) {
throw new Error(`${name} internal dispatch replaced the accepted callback tuple`)
}
}
/** Invoke one resolved observe-only listener snapshot with per-listener containment. */
function invokeContainedSessionObservers(
ctx: Context,
name: 'session/event' | 'session/disposed',
id: SessionId,
args: unknown[],
callbacks: SessionCallback[],
): void {
for (const callback of callbacks) {
try {
const returned: unknown = callback(...args)
void Promise.resolve(returned).catch((error: unknown) => {
warnContained(ctx, `session "${id}": ${name} listener rejected: ${renderThrown(error)}`)
})
} catch (error: unknown) {
warnContained(ctx, `session "${id}": ${name} listener threw: ${renderThrown(error)}`)
}
}
}
interface SessionAppendHooks {
/** Keep the store attachment live through acceptance and publication. */
begin(): void
/** Resolve the exact observer list before commit; returns its contained publisher. */
prepareObservation(event: SessionEvent): () => void
/** Release the attachment barrier and honor a deferred detach. */
end(): void
}
const appendHooks = new WeakMap<Session, SessionAppendHooks>()
/** Identity token replaced on every store attachment or detachment. */
const attachmentEpochs = new WeakMap<Session, object>()
/**
* An event-sourced session: an append-only log of {@link SessionEvent}s.
@@ -289,6 +349,8 @@ const appendObservers = new WeakMap<Session, (event: SessionEvent) => void>()
*/
export class Session {
private log: SessionEvent[] = []
/** True throughout one event's materialization, validation, commit, and publication. */
private appendInProgress = false
/**
* Derived surface — a cached linked list of message-producing events.
@@ -393,8 +455,11 @@ export class Session {
/**
* Append one typed event to the log and synchronously notify observers via
* the store-owned, module-private append observer. The hot path never blocks
* on I/O — persistence plugins buffer asynchronously.
* the store-owned, module-private publication hooks. The hot path never blocks
* on I/O — persistence plugins buffer asynchronously. Once the event enters
* the log, the append is committed: observer failures are logged and
* contained per listener, so they do not change the return value or prevent
* later listeners from observing the same accepted event.
*
* @param type - The event type (key of {@link SessionEventMap}).
* @param data - The event payload; must be JSON-serializable.
@@ -416,7 +481,9 @@ export class Session {
* copies each nested value once, so a stateful getter cannot supply one value
* to validation and another to storage. The event log is the durable source
* of truth, so a bad event fails at the append site rather than later during
* a backend flush.
* a backend flush. A synchronous internal dispatch validation failure or an
* append reentered while this acceptance/publication boundary is open also
* rejects before the log changes.
*/
append<T extends SessionEventType>(
type: T,
@@ -426,60 +493,87 @@ export class Session {
if (typeof type !== 'string') {
throw new TypeError('session event type must be a string')
}
const surfaceOpts: SurfaceIntent | undefined = opts[0]
const sourceEventSeqs = surfaceOpts?.sourceEventSeqs
const surfaceOp = surfaceOpts?.surfaceOp
// Surface-eligible events MUST carry a surfaceOp marker — the surface is the
// sole source of derived history, so a marker-less message event would be
// logged yet vanish from deriveMessages(). The typed `opts` overload makes
// the marker mandatory only when `T` is a SPECIFIC SurfaceEventType literal;
// when `T` widens to the SessionEventType union (a caller iterating raw
// events: `for (const e of log) append(e.type, e.data)`), the conditional
// rest collapses to optional and the compiler stops enforcing it. Re-check
// at runtime so that loophole can't silently drop history.
const surfaceMetadata = {
...sourceEventSeqs !== undefined ? { sourceEventSeqs } : {},
...surfaceOp !== undefined ? { surfaceOp } : {},
if (this.appendInProgress) {
throw new Error('session append cannot reenter while another append is being accepted or published')
}
// The caller still owns the data and metadata objects and could mutate them
// after append. Materialize each accepted value exactly once while checking
// its JSON vocabulary, so the log cannot drift and a stateful getter cannot
// show one value to validation and another to a prototype-erasing clone. The
// returned event carries these SAME snapshots.
//
// Surface metadata accessors are read once into one plain record; the
// recursive snapshot then reads each nested value once as it copies it.
// Build the event shape with conditional surface fields via spreading.
// The result is cast through `unknown` because the conditional spreads
// produce an intersection type that the assignability checker can't
// narrow to a specific discriminated-union member when T is generic.
// This is a safe internal boundary: data and surface metadata are
// materialized below before the event enters the log.
const dataSnapshot = snapshotJsonValue(data)
if (dataSnapshot === undefined) {
throw new Error(`session event "${type}" carries non-JSON-serializable data`)
const hooks = appendHooks.get(this)
const attachmentEpoch = attachmentEpochs.get(this)
this.appendInProgress = true
try {
// Start before reading caller-owned fields: a getter may request detach
// or try to append reentrantly. The attachment and sequence boundary stay
// stable until this exact acceptance attempt has either failed or reached
// every post-commit observer.
hooks?.begin()
const surfaceOpts: SurfaceIntent | undefined = opts[0]
const sourceEventSeqs = surfaceOpts?.sourceEventSeqs
const surfaceOp = surfaceOpts?.surfaceOp
// Surface-eligible events MUST carry a surfaceOp marker — the surface is the
// sole source of derived history, so a marker-less message event would be
// logged yet vanish from deriveMessages(). The typed `opts` overload makes
// the marker mandatory only when `T` is a SPECIFIC SurfaceEventType literal;
// when `T` widens to the SessionEventType union (a caller iterating raw
// events: `for (const e of log) append(e.type, e.data)`), the conditional
// rest collapses to optional and the compiler stops enforcing it. Re-check
// at runtime so that loophole can't silently drop history.
const surfaceMetadata = {
...sourceEventSeqs !== undefined ? { sourceEventSeqs } : {},
...surfaceOp !== undefined ? { surfaceOp } : {},
}
// The caller still owns the data and metadata objects and could mutate them
// after append. Materialize each accepted value exactly once while checking
// its JSON vocabulary, so the log cannot drift and a stateful getter cannot
// show one value to validation and another to a prototype-erasing clone. The
// returned event carries these SAME snapshots.
//
// Surface metadata accessors are read once into one plain record; the
// recursive snapshot then reads each nested value once as it copies it.
// Build the event shape with conditional surface fields via spreading.
// The result is cast through `unknown` because the conditional spreads
// produce an intersection type that the assignability checker can't
// narrow to a specific discriminated-union member when T is generic.
// This is a safe internal boundary: data and surface metadata are
// materialized below before the event enters the log.
const dataSnapshot = snapshotJsonValue(data)
if (dataSnapshot === undefined) {
throw new Error(`session event "${type}" carries non-JSON-serializable data`)
}
const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata)
if (surfaceMetadataSnapshot === undefined) {
throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)
}
assertSurfaceMetadataShape(
type,
(surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp,
(surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs,
)
if (appendHooks.get(this) !== hooks || attachmentEpochs.get(this) !== attachmentEpoch) {
throw new Error('session attachment changed while append input was being accepted')
}
const event = {
type,
seq: this.log.length,
time: Date.now(),
data: dataSnapshot,
...surfaceMetadataSnapshot,
} as unknown as SessionEvent<T>
const acceptedEvent = deepFreeze(event)
// Resolve dispatch before the log push. Cordis runs internal/dispatch
// while producing this list; if instrumentation rejects the carrier, the
// append still fails before commit. The resolved callbacks themselves are
// observe-only and run with per-listener containment after the push.
const publish = hooks?.prepareObservation(acceptedEvent as unknown as SessionEvent)
this.log.push(acceptedEvent as unknown as SessionEvent)
this.eventsSnapshot = undefined
publish?.()
return acceptedEvent
} finally {
try {
hooks?.end()
} finally {
this.appendInProgress = false
}
}
const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata)
if (surfaceMetadataSnapshot === undefined) {
throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)
}
assertSurfaceMetadataShape(
type,
(surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp,
(surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs,
)
const event = {
type,
seq: this.log.length,
time: Date.now(),
data: dataSnapshot,
...surfaceMetadataSnapshot,
} as unknown as SessionEvent<T>
const acceptedEvent = deepFreeze(event)
this.log.push(acceptedEvent as unknown as SessionEvent)
this.eventsSnapshot = undefined
appendObservers.get(this)?.(acceptedEvent as unknown as SessionEvent)
return acceptedEvent
}
/** Cached fold of the request-header events — see {@link requestHeader}. */
@@ -674,7 +768,9 @@ export class SessionStore extends Service {
private announced = new WeakSet<Session>()
/** Entries currently dispatching `session/created`; detach waits for dispatch to unwind. */
private announcing = new WeakSet<Session>()
/** A detach requested reentrantly from `session/created`. */
/** Entries accepting or publishing an append; detach waits for the boundary to unwind. */
private appending = new WeakSet<Session>()
/** A detach requested reentrantly from creation or append publication. */
private pendingDetach = new WeakSet<Session>()
/** Unpublished identities held across factory load/setup transactions. */
private reservations = new Map<SessionId, SessionRegistrationReservation>()
@@ -746,7 +842,7 @@ export class SessionStore extends Service {
* fills `version`/`id`/`createdAt`).
*
* For an agent whose session must be torn down IN ORDER with its loop (so the
* loop's final flush is captured before the store-owned observer detaches), do NOT use this
* loop's final flush is captured before the store attachment ends), do NOT use this
* — fold the session lifecycle into the agent's own effect via
* {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s
* `startOwned`).
@@ -763,7 +859,7 @@ export class SessionStore extends Service {
// Single effect owned by the calling fiber. Yield the detach BEFORE
// announcing so a throwing `session/created` listener rolls the attach back
// (the generator effect disposes already-yielded disposers on a throw)
// instead of leaking the store entry + append observer.
// instead of leaking the store entry and its publication hooks.
this.ctx.effect(function* (this: SessionStore) {
yield this.enter(session)
this.announce(session)
@@ -777,7 +873,7 @@ export class SessionStore extends Service {
* Pairs with {@link enter} + {@link announce}: a caller that owns a composite
* `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
* effect so a fiber unload tears the session + agent down as a single ORDERED
* chain rather than as racing sibling effects — which would detach the append observer
* chain rather than as racing sibling effects — which would remove the publication hooks
* before the loop's closing `session/flush`, dropping the closing events.
*
* @param id - the session id; omitted, the store mints `session-<n>`.
@@ -827,9 +923,9 @@ export class SessionStore extends Service {
}
/**
* Enter a {@link prepare}d session into the store: wire the module-private
* append observer to `session/event` and add it to the store. Returns the
* DETACH disposer (observer + store removal). Does NOT emit `session/created` —
* Enter a {@link prepare}d session into the store: install the module-private
* append publication hooks and add it to the store. Returns the DETACH
* disposer (hooks + store removal). Does NOT emit `session/created` —
* the caller yields this disposer inside its effect and THEN calls
* {@link announce}, so a throwing `session/created` listener rolls the attach
* back instead of leaking it.
@@ -845,7 +941,7 @@ export class SessionStore extends Service {
* @param session - a {@link prepare}d session not yet in the store.
* @param reservation - the exact unpublished-id capability when a factory
* reserved this session across setup.
* @returns the detach disposer (observer + store removal). When called from
* @returns the detach disposer (publication hooks + store removal). When called from
* a synchronous `session/created` listener, removal and disposal wait until
* that creation dispatch unwinds.
* @throws if a session with this id is already in the store.
@@ -863,7 +959,7 @@ export class SessionStore extends Service {
if (this.store.has(id) || this.enteringIds.has(id)) {
throw new Error(`session "${id}" already exists`)
}
if (appendObservers.has(session)) throw new Error(`session "${id}" is already attached to a store`)
if (appendHooks.has(session)) throw new Error(`session "${id}" is already attached to a store`)
this.enteringIds.add(id)
// The carrier is decided HERE, once, from the ENTERING context's scope tag
// (`this.ctx` is the caller's context — the tracker mechanism): every
@@ -889,20 +985,39 @@ export class SessionStore extends Service {
}
/* v8 ignore next 1 -- enteringIds prevents a same-store commit during carrier construction */
if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
if (appendObservers.has(session)) throw new Error(`session "${id}" is already attached to a store`)
if (appendHooks.has(session)) throw new Error(`session "${id}" is already attached to a store`)
this.carriers.set(session, carrier)
const emitCtx = this.ctx
appendObservers.set(session, (event) => { emitCtx.emit(carrier, 'session/event', session, event) })
appendHooks.set(session, {
begin: () => { this.appending.add(session) },
prepareObservation(event) {
// Cordis removes carrier/name in place and exposes the remaining array
// to internal/dispatch. Resolve with a throwaway array so an internal
// checker cannot replace the tuple later observers receive.
const dispatchArgs: unknown[] = [carrier, 'session/event', session, event]
const callbackArgs: unknown[] = [session, event]
const callbacks = collectSessionCallbacks(emitCtx, dispatchArgs)
assertDispatchTuple('session/event', dispatchArgs, callbackArgs)
return () => { invokeContainedSessionObservers(emitCtx, 'session/event', id, callbackArgs, callbacks) }
},
end: () => {
this.appending.delete(session)
if (this.pendingDetach.has(session) && !this.announcing.has(session)) {
this.detachEntered(session, id, carrier)
}
},
})
attachmentEpochs.set(session, {})
this.acceptedIds.set(session, id)
this.store.set(id, session)
let entered = true
const detach = (): void => {
if (!entered) return
entered = false
// A creation listener may own the advanced detach capability. Keep the
// entry and its event observer live until the synchronous creation
// dispatch unwinds, then publish the paired disposal edge.
if (this.announcing.has(session)) {
// 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 (this.announcing.has(session) || this.appending.has(session)) {
this.pendingDetach.add(session)
return
}
@@ -920,7 +1035,8 @@ export class SessionStore extends Service {
* remains the exact-identity backstop against future mutation paths */
if (this.store.get(id) !== session || this.acceptedIds.get(session) !== id) return
const wasAnnounced = this.announced.delete(session)
appendObservers.delete(session)
appendHooks.delete(session)
attachmentEpochs.set(session, {})
this.acceptedIds.delete(session)
this.carriers.delete(session)
this.store.delete(id)
@@ -943,38 +1059,40 @@ export class SessionStore extends Service {
// throw. Rollback must still pair that partial creation with disposal, and
// a listener cannot recursively create a second lifecycle edge.
this.announced.add(session)
const args: unknown[] = [carrier, 'session/created', session]
const dispatchArgs: unknown[] = [carrier, 'session/created', session]
const callbackArgs: unknown[] = [session]
this.announcing.add(session)
try {
for (const callback of this.ctx.events.dispatch('emit', args)) {
const callbacks = collectSessionCallbacks(this.ctx, dispatchArgs)
assertDispatchTuple('session/created', dispatchArgs, callbackArgs)
for (const callback of callbacks) {
// Synchronous throws intentionally propagate and veto publication; the
// yielded detach then emits the paired disposal edge. An async function
// is nevertheless assignable to a void listener, so observe its returned
// promise: rejection is too late to roll back and must be logged instead
// of becoming unhandled.
const returned: unknown = callback(...args)
const returned: unknown = callback(...callbackArgs)
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`session "${id}": session/created listener rejected: ${renderThrown(error)}`)
warnContained(this.ctx, `session "${id}": session/created listener rejected: ${renderThrown(error)}`)
})
}
} finally {
this.announcing.delete(session)
if (this.pendingDetach.has(session)) this.detachEntered(session, id, carrier)
if (this.pendingDetach.has(session) && !this.appending.has(session)) {
this.detachEntered(session, id, carrier)
}
}
}
/** Emit the paired teardown notification with per-listener containment. */
private emitDisposed(session: Session, carrier: Scoped<Session>, id: SessionId): void {
const args: unknown[] = [carrier, 'session/disposed', session]
for (const callback of this.ctx.events.dispatch('emit', args)) {
try {
const returned: unknown = callback(...args)
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`session "${id}": session/disposed listener rejected: ${renderThrown(error)}`)
})
} catch (error: unknown) {
this.ctx.logger.warn(`session "${id}": session/disposed listener threw: ${renderThrown(error)}`)
}
const dispatchArgs: unknown[] = [carrier, 'session/disposed', session]
const callbackArgs: unknown[] = [session]
try {
const callbacks = collectSessionCallbacks(this.ctx, dispatchArgs)
invokeContainedSessionObservers(this.ctx, 'session/disposed', id, callbackArgs, callbacks)
} catch (error: unknown) {
warnContained(this.ctx, `session "${id}": session/disposed dispatch threw: ${renderThrown(error)}`)
}
}
@@ -986,10 +1104,27 @@ export class SessionStore extends Service {
* raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the
* scoped-dispatch invariant can pin it.
* @param session - the session whose buffered events must reach durable storage.
* @returns resolves when every flush listener has settled; rejects if one rejects.
* @returns resolves when every flush listener has settled; after all settle,
* rejects with the first registered listener failure if any listener failed.
*/
async flush(session: Session): Promise<void> {
await this.ctx.parallel(this.liveEntryFor(session).carrier, 'session/flush', session)
const { carrier } = this.liveEntryFor(session)
const dispatchArgs: unknown[] = [carrier, 'session/flush', session]
const callbackArgs: unknown[] = [session]
const callbacks = collectSessionCallbacks(this.ctx, dispatchArgs)
assertDispatchTuple('session/flush', dispatchArgs, callbackArgs)
const results = await Promise.allSettled(callbacks.map((callback) => {
try {
return callback(...callbackArgs)
} catch (error: unknown) {
// Preserve the listener's exact rejection value; flush is a caller-owned
// failure boundary, and Cordis listeners may throw arbitrary values.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
return Promise.reject(error)
}
}))
const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
if (failure !== undefined) throw failure.reason
}
/** Return the exact live session's accepted id and carrier; detached/prepared objects reject. */

View File

@@ -108,6 +108,40 @@ describe('sessions.flush()', () => {
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
})
it('does not let a synchronous flush failure starve later listeners', async () => {
const ctx = await mount()
const flushed: Session[] = []
ctx.on('session/flush', () => { throw new Error('disk full') })
ctx.on('session/flush', (session) => { flushed.push(session) })
const session = ctx.sessions.create()
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
expect(flushed).toEqual([session])
})
it('waits for slower flush listeners before reporting another listener failure', async () => {
const ctx = await mount()
const gate = Promise.withResolvers<undefined>()
let slowStarted = false
let settled = false
ctx.on('session/flush', () => Promise.reject(new Error('disk full')))
ctx.on('session/flush', () => {
slowStarted = true
return gate.promise
})
const session = ctx.sessions.create()
const flushing = ctx.sessions.flush(session)
void flushing.finally(() => { settled = true }).catch(() => undefined)
await Promise.resolve()
expect(slowStarted).toBe(true)
expect(settled).toBe(false)
gate.resolve(undefined)
await expect(flushing).rejects.toThrow('disk full')
expect(settled).toBe(true)
})
it('rejects a never-entered session instead of inventing a carrier', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'owner')
@@ -120,6 +154,21 @@ describe('sessions.flush()', () => {
expect(flushed).toEqual([])
})
it('rejects internal dispatch substitution before flush callbacks run', async () => {
const ctx = await mount()
const session = ctx.sessions.create()
const replacement = ctx.sessions.create()
const flushed: Session[] = []
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name === 'session/flush') args[0] = replacement
})
ctx.on('session/flush', (candidate) => { flushed.push(candidate) })
await expect(ctx.sessions.flush(session))
.rejects.toThrow('session/flush internal dispatch replaced the accepted callback tuple')
expect(flushed).toEqual([])
})
it('clears a detached carrier and rejects stale flushes', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'owner')

View File

@@ -702,7 +702,7 @@ describe('SessionStore', () => {
const session = ctx.sessions.create()
expect(created).toEqual([session])
// The store-owned append observer is module-private. A JavaScript caller
// The store-owned append publication hooks are module-private. A JavaScript caller
// may create an unrelated property with the old implementation's name,
// but cannot suppress the durable event feed.
expect(Reflect.set(session, 'onAppend', undefined)).toBe(true)
@@ -1120,7 +1120,7 @@ describe('SessionStore', () => {
expect(disposed.map(session => session.id)).toEqual(['fixed'])
// A subsequent create of the SAME id succeeds (the already-exists check is
// not wedged) and its store-owned observer is correctly wired (events observable).
// not wedged) and its store-owned publication hooks are correctly wired.
const events: SessionEvent[] = []
ctx.on('session/event', (_session, event) => void events.push(event))
const session = ctx.sessions.create(SessionId('fixed'))
@@ -1129,6 +1129,277 @@ describe('SessionStore', () => {
expect(events).toHaveLength(1)
})
it('contains session/event observer failures after the append commit point', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const session = ctx.sessions.create(SessionId('contained-event'))
const heard: SessionEvent[] = []
let committedBeforeNotify = false
ctx.on('session/event', (observedSession, event) => {
committedBeforeNotify = observedSession.events.at(-1) === event
throw new Error('sync event observer')
})
ctx.on('session/event', () => Promise.reject(new Error('async event observer')) as never)
ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
let appended!: SessionEvent
expect(() => {
appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
}).not.toThrow()
expect(committedBeforeNotify).toBe(true)
expect(session.events).toEqual([appended])
expect(heard).toEqual([appended])
await Promise.resolve()
await Promise.resolve()
expect(warnings).toEqual([
'session "contained-event": session/event listener threw: Error: sync event observer',
'session "contained-event": session/event listener rejected: Error: async event observer',
])
})
it('runs internal dispatch validation on one frozen candidate before commit and resets after a veto', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('dispatch-veto'))
const validations: Array<{ event: SessionEvent; logLength: number; frozen: boolean }> = []
const observed: SessionEvent[] = []
let reject = true
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const [observedSession, event] = args as [Session, SessionEvent]
validations.push({
event,
logLength: observedSession.events.length,
frozen: Object.isFrozen(event) && Object.isFrozen(event.data),
})
if (reject) {
reject = false
throw new Error('reject first candidate')
}
})
ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
expect(() => session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).toThrow('reject first candidate')
expect(session.events).toEqual([])
expect(observed).toEqual([])
const appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
expect(validations.map(({ logLength, frozen }) => ({ logLength, frozen }))).toEqual([
{ logLength: 0, frozen: true },
{ logLength: 0, frozen: true },
])
expect(validations.map(({ event }) => event.seq)).toEqual([0, 0])
expect(validations[1]!.event).toBe(appended)
expect(session.events).toEqual([appended])
expect(observed).toEqual([appended])
})
it('resolves session/event dispatch before commit so instrumentation failure cannot hide a logged event', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('dispatch-check'))
const observed: SessionEvent[] = []
ctx.on('internal/dispatch', (_mode, name) => {
if (name === 'session/event') throw new Error('dispatch instrumentation rejected the carrier')
})
ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
expect(() => session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).toThrow('dispatch instrumentation rejected the carrier')
expect(session.events).toEqual([])
expect(observed).toEqual([])
})
it('rejects prepend or append instrumentation that replaces the accepted observer tuple', async () => {
for (const prepend of [true, false]) {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId(`dispatch-tuple-${prepend}`))
const replacementSession = new Session(SessionId('replacement'))
const replacementEvent = {
type: 'turn/end',
seq: 99,
time: 1,
data: { turn: 99, reason: { kind: 'completed' } },
} as SessionEvent
const observed: Array<{ session: Session; event: SessionEvent }> = []
let replace = true
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event' || !replace) return
args[0] = replacementSession
args[1] = replacementEvent
}, { prepend })
ctx.on('session/event', (observedSession, event) => {
observed.push({ session: observedSession, event })
})
expect(() => session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).toThrow('session/event internal dispatch replaced the accepted callback tuple')
expect(session.events).toEqual([])
expect(observed).toEqual([])
replace = false
const appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
expect(session.events).toEqual([appended])
expect(observed).toEqual([{ session, event: appended }])
}
})
it('rejects if a bare session becomes attached while caller data is materialized', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = new Session(SessionId('attach-during-append'))
const observed: SessionEvent[] = []
let sessionEventDispatches = 0
let detach!: () => void
ctx.on('internal/dispatch', (_mode, name) => {
if (name === 'session/event') sessionEventDispatches += 1
})
ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
const data = {
get todos(): TodoItem[] {
detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
return []
},
}
expect(() => session.append('todo/write', data))
.toThrow('session attachment changed while append input was being accepted')
expect(ctx.sessions.get(session.id)).toBe(session)
expect(session.events).toEqual([])
expect(sessionEventDispatches).toBe(0)
expect(observed).toEqual([])
const appended = session.append('todo/write', { todos: [] })
expect(session.events).toEqual([appended])
expect(sessionEventDispatches).toBe(1)
expect(observed).toEqual([appended])
detach()
})
it('rejects a transient attach and detach while caller data is materialized', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = new Session(SessionId('attach-detach-during-append'))
const lifecycle: string[] = []
const observed: SessionEvent[] = []
ctx.on('session/created', () => { lifecycle.push('created') })
ctx.on('session/disposed', () => { lifecycle.push('disposed') })
ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
const data = {
get todos(): TodoItem[] {
const detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
detach()
return []
},
}
expect(() => session.append('todo/write', data))
.toThrow('session attachment changed while append input was being accepted')
expect(ctx.sessions.get(session.id)).toBeUndefined()
expect(session.events).toEqual([])
expect(lifecycle).toEqual(['created', 'disposed'])
expect(observed).toEqual([])
})
it('contains a reentrant observer append without reordering later observers', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const session = ctx.sessions.create(SessionId('reentrant-observer'))
const heard: SessionEvent[] = []
ctx.on('session/event', (observedSession) => {
observedSession.append('todo/write', { todos: [] })
})
ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
const appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
expect(session.events).toEqual([appended])
expect(heard).toEqual([appended])
expect(warnings).toEqual([
'session "reentrant-observer": session/event listener threw: Error: session append cannot reenter while another append is being accepted or published',
])
})
it('keeps observer failures contained when warning output itself throws', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.logger.warn = (() => { throw new Error('logger unavailable') }) as typeof ctx.logger.warn
const session = ctx.sessions.create(SessionId('throwing-logger'))
const heard: SessionEvent[] = []
ctx.on('session/event', () => { throw new Error('sync observer') })
ctx.on('session/event', () => Promise.reject(new Error('async observer')) as never)
ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
let appended!: SessionEvent
expect(() => {
appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
}).not.toThrow()
await Promise.resolve()
await Promise.resolve()
expect(session.events).toEqual([appended])
expect(heard).toEqual([appended])
})
it('defers detach through dispatch resolution, commit, and observer publication', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const order: string[] = []
const session = ctx.sessions.prepare(SessionId('detach-during-append'))
const detach = ctx.sessions.enter(session)
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const session = args[0] as Session
order.push(`resolve:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
detach()
})
ctx.on('session/event', (session) => {
order.push(`observe:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
})
ctx.on('session/disposed', (session) => {
order.push(`dispose:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
})
ctx.sessions.announce(session)
const appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
expect(session.events).toEqual([appended])
expect(order).toEqual(['resolve:live', 'observe:live', 'dispose:detached'])
expect(ctx.sessions.get(session.id)).toBeUndefined()
})
it('observes async session/created rejection without rolling back or starving peers', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -1181,6 +1452,46 @@ describe('SessionStore', () => {
'session "contained-disposal": session/disposed listener rejected: Error: async disposed',
])
})
it('contains internal dispatch failure after session detachment', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const heard: Session[] = []
ctx.on('internal/dispatch', (_mode, name) => {
if (name === 'session/disposed') throw new Error('disposed dispatch instrumentation')
})
ctx.on('session/disposed', (session) => { heard.push(session) })
const session = ctx.sessions.prepare(SessionId('disposed-dispatch'))
const detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
expect(() => { detach() }).not.toThrow()
expect(ctx.sessions.get(session.id)).toBeUndefined()
expect(heard).toEqual([])
expect(warnings).toEqual([
'session "disposed-dispatch": session/disposed dispatch threw: Error: disposed dispatch instrumentation',
])
})
it('does not let internal dispatch replace the disposed callback tuple', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const replacement = new Session(SessionId('replacement-disposed'))
const heard: Session[] = []
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name === 'session/disposed') args[0] = replacement
})
ctx.on('session/disposed', (session) => { heard.push(session) })
const session = ctx.sessions.prepare(SessionId('fixed-disposed-tuple'))
const detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
detach()
expect(heard).toEqual([session])
})
})
describe('todo/write event', () => {