fix(scope): harden final ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-12 05:13:17 +08:00
parent 36b8370027
commit a9cb70d896
52 changed files with 2839 additions and 514 deletions

View File

@@ -4,7 +4,7 @@ Event-sourced session log and in-memory store. A `Session` is the append-only so
## Service: `SessionStore` (ctx key: `sessions`)
Creates and holds event-sourced `Session` instances. Persistence is intentionally not implemented here — plugins subscribe to `session/event` and flush on `session/flush`.
Creates and holds event-sourced `Session` instances. Persistence is intentionally not implemented here — plugins subscribe to `session/event`, flush on `session/flush`, and may mirror the paired `session/created`/`session/disposed` lifecycle.
### Public API
@@ -16,17 +16,18 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
#### 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 `onAppend` 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-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:
- `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.enter(session): () => void` — wire `onAppend``session/event`, capture its scope carrier, and add the session to the store; returns the idempotent DETACH disposer, which clears both notification and carrier state. 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 stale prepared object must not overwrite a live same-id session.
- `ctx.sessions.announce(session): void` — emit `session/created` for an entered session.
- `ctx.sessions.reserve(id): SessionRegistrationReservation` — hold an unpublished id under the calling fiber and construct its one owned Session through `reservation.prepare(options?)`. Until `release()` or owner unload, bare `prepare`/`create`/`enter` calls for that 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` — install the module-private `session/event` observer, capture its scope carrier, and add the session under one accepted id; returns the idempotent DETACH disposer, which clears notification, carrier, and accepted-key state. 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 stale prepared object must not overwrite a live same-id session. 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. Its 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 announces creation, publishes each append, and provides an awaited durability checkpoint. 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. 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.
### Class: `Session`
@@ -37,8 +38,8 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `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.
- `session.events` — a cached, frozen array snapshot over deep-frozen events. Repeated reads without an append return the same array; an append invalidates the cache and the next read returns a new snapshot, while earlier snapshots stay unchanged. Neither a cast nor a retained reference can push into the live log or rewrite an accepted event.
- `session.seq`, `session.id`
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates its lossless-JSON shape and requires the header id to match `session.id`, so a caller cannot later mutate persistence routing or lineage through an aliased header. Kept out of the event log (a storage concern, not replayable state); a minimal header (stamped with the current `SESSION_FORMAT_VERSION`) is synthesized for bare `Session` construction.
- `session.seq`, `session.id``id` is a non-writable, non-configurable runtime identity slot, not merely TypeScript-readonly.
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`) published through a non-writable, non-configurable slot. Construction validates its lossless-JSON shape and requires the header id to match `session.id`, so a caller cannot later replace or mutate persistence routing or lineage. Kept out of the event log (a storage concern, not replayable state); a minimal header (stamped with the current `SESSION_FORMAT_VERSION`) is synthesized for bare `Session` construction.
### Lossless JSON utilities

View File

@@ -34,7 +34,10 @@ declare module 'cordis' {
interface Events {
/**
* A session was created in the store.
* A session was created in the store. A synchronous listener throw vetoes
* publication and rollback emits the matching `session/disposed` edge;
* returned-promise rejection is observed and logged but cannot retroactively
* veto this synchronous boundary.
* 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
@@ -45,6 +48,18 @@ declare module 'cordis' {
* @mode emit
*/
'session/created'(this: Scoped<Session>, session: Session): void
/**
* A previously announced session left the store. Emitted exactly once on
* normal detach or publication rollback, and never for a prepared/entered
* session whose `session/created` announcement did not begin. Listener
* failures (including returned-promise rejections) are logged and contained
* per listener so teardown always reaches quiescence.
* Scope-filtered dispatch uses the same owner carrier captured at entry;
* agent-scoped listeners hear only their own session's teardown.
* @param session - the session that is no longer live in the store.
* @mode emit
*/
'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.
@@ -253,6 +268,17 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
}
}
/** Render an arbitrary thrown value without allowing coercion to throw again. */
function renderThrown(value: unknown): string {
try {
return value instanceof Error ? `${value.name}: ${value.message}` : String(value)
} catch {
return '<unrenderable thrown value>'
}
}
const appendObservers = new WeakMap<Session, (event: SessionEvent) => void>()
/**
* An event-sourced session: an append-only log of {@link SessionEvent}s.
*
@@ -261,8 +287,6 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
*/
export class Session {
private log: SessionEvent[] = []
/** Set by the store so appends are observable; undefined when detached. */
onAppend: ((event: SessionEvent) => void) | undefined
/**
* Derived surface — a cached linked list of message-producing events.
@@ -336,6 +360,14 @@ export class Session {
})
}
this.header = snapshotSessionHeader(id, header)
// TypeScript readonly prevents ordinary typed assignment only. Pin both
// public identity bindings at runtime too: setup/plugins receive the live
// Session object, and replacing either slot would split registry keys,
// persistence routing, and the already-validated header.
Object.defineProperties(this, {
id: { value: id, enumerable: true, writable: false, configurable: false },
header: { value: this.header, enumerable: true, writable: false, configurable: false },
})
}
/** Cached immutable public snapshot of the private append-only log. */
@@ -359,8 +391,8 @@ export class Session {
/**
* Append one typed event to the log and synchronously notify observers via
* `onAppend`. The hot path never blocks on I/O — persistence plugins buffer
* asynchronously.
* the store-owned, module-private append observer. The hot path never blocks
* on I/O — persistence plugins buffer asynchronously.
*
* @param type - The event type (key of {@link SessionEventMap}).
* @param data - The event payload; must be JSON-serializable.
@@ -444,7 +476,7 @@ export class Session {
const acceptedEvent = deepFreeze(event)
this.log.push(acceptedEvent as unknown as SessionEvent)
this.eventsSnapshot = undefined
this.onAppend?.(acceptedEvent as unknown as SessionEvent)
appendObservers.get(this)?.(acceptedEvent as unknown as SessionEvent)
return acceptedEvent
}
@@ -599,6 +631,29 @@ export class SessionForkError extends Error {
}
}
/**
* Unforgeable ownership handle for one unpublished session id. A factory keeps
* this capability across load/setup, preventing setup code from entering the
* prepared Session or publishing a replacement under the same id. Obtain it
* only from {@link SessionStore.reserve}.
*/
export interface SessionRegistrationReservation {
/** The reserved store id. */
readonly id: SessionId
/**
* Construct the one Session owned by this reservation.
* @param options - seed events and creation metadata.
* @returns the still-unpublished Session.
*/
prepare(options?: CreateSessionOptions): Session
/**
* Release the unpublished reservation; idempotent. The store also releases
* it automatically when the fiber that called `reserve` disposes.
* @returns nothing.
*/
release(): void
}
/**
* In-memory session store (`ctx.sessions`).
*
@@ -607,6 +662,14 @@ export class SessionForkError extends Error {
*/
export class SessionStore extends Service {
private store = new Map<SessionId, Session>()
/** The one accepted map key for each live session; never reread caller state. */
private acceptedIds = new WeakMap<Session, SessionId>()
/** Sessions whose creation announcement began and therefore require a pair. */
private announced = new WeakSet<Session>()
/** Unpublished identities held across factory load/setup transactions. */
private reservations = new Map<SessionId, SessionRegistrationReservation>()
/** The exact prepared object owned by each reservation capability. */
private reservedSessions = new WeakMap<SessionRegistrationReservation, Session>()
/**
* Each live session's dispatch carrier, captured at {@link enter} from the
* ENTERING context's scope tag (an agent session is entered through
@@ -621,6 +684,59 @@ export class SessionStore extends Service {
super(ctx, 'sessions')
}
/**
* Reserve one unpublished session id across an asynchronous factory
* transaction. Bare `prepare`/`create`/`enter` calls for the id reject until
* release; the capability constructs exactly one Session and is passed back
* to {@link enter} at publication. The reservation belongs to the calling
* fiber, so owner unload releases an abandoned id automatically.
* @param id - the session id the transaction will publish.
* @returns the opaque reservation capability.
* @throws if the id is malformed, live, or already reserved.
*/
reserve(id: SessionId): SessionRegistrationReservation {
if (typeof id !== 'string') throw new TypeError('session id must be a string')
if (this.store.has(id) || this.reservations.has(id)) {
throw new Error(`session "${id}" already exists or is reserved`)
}
let active = true
let prepared = false
const rawRelease = (): void => {
if (!active) return
active = false
this.reservedSessions.delete(reservation)
this.reservations.delete(id)
}
let disposeEffect!: () => Promise<void> | void
const reservation: SessionRegistrationReservation = Object.freeze({
id,
prepare: (options?: CreateSessionOptions) => {
if (!active) {
throw new Error(`session "${id}" reservation is no longer active`)
}
if (prepared) throw new Error(`session "${id}" reservation already prepared a session`)
prepared = true
const session = this.prepareReserved(id, options, reservation)
this.reservedSessions.set(reservation, session)
return session
},
release: () => {
rawRelease()
// Remove the now-inert ownership effect on manual transaction settle;
// its cleanup is the exact idempotent raw release above.
void disposeEffect()
},
})
this.reservations.set(id, reservation)
try {
disposeEffect = this.ctx.effect(() => rawRelease, `sessions.reserve(${id})`)
} catch (error: unknown) {
rawRelease()
throw error
}
return reservation
}
/**
* Create a session owned by the calling fiber: disposing that fiber stops
* event notification and removes the session from the store. `options.seed`
@@ -630,7 +746,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 `onAppend` detaches), do NOT use this
* loop's final flush is captured before the store-owned observer detaches), 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`).
@@ -647,7 +763,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 + onAppend.
// instead of leaking the store entry + append observer.
this.ctx.effect(function* (this: SessionStore) {
yield this.enter(session)
this.announce(session)
@@ -661,7 +777,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 `onAppend`
* chain rather than as racing sibling effects — which would detach the append observer
* before the loop's closing `session/flush`, dropping the closing events.
*
* @param id - the session id; omitted, the store mints `session-<n>`.
@@ -672,7 +788,27 @@ export class SessionStore extends Service {
* non-absolute path.
*/
prepare(id?: SessionId, options?: CreateSessionOptions): Session {
const sessionId = SessionId(id ?? `session-${++this.counter}`)
return this.prepareReserved(id, options)
}
/** Shared prepare implementation, optionally authorized by a reservation. */
private prepareReserved(
id?: SessionId,
options?: CreateSessionOptions,
reservation?: SessionRegistrationReservation,
): Session {
let sessionId: SessionId
if (id === undefined) {
do sessionId = SessionId(`session-${++this.counter}`)
while (this.store.has(sessionId) || this.reservations.has(sessionId))
} else {
sessionId = SessionId(id)
}
if (typeof sessionId !== 'string') throw new TypeError('session id must be a string')
const held = this.reservations.get(sessionId)
if (reservation === undefined && held !== undefined) {
throw new Error(`session "${sessionId}" is reserved for unpublished creation`)
}
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
const seed = options?.seed
const meta = snapshotSessionMeta(options?.meta)
@@ -691,9 +827,9 @@ export class SessionStore extends Service {
}
/**
* Enter a {@link prepare}d session into the store: wire `onAppend` →
* `session/event` and add it to the store. Returns the DETACH disposer
* (`onAppend = undefined` + store removal). Does NOT emit `session/created` —
* 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` —
* 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.
@@ -707,11 +843,23 @@ export class SessionStore extends Service {
* assume that.
*
* @param session - a {@link prepare}d session not yet in the store.
* @returns the detach disposer (`onAppend = undefined` + store removal).
* @param reservation - the exact unpublished-id capability when a factory
* reserved this session across setup.
* @returns the detach disposer (observer + store removal).
* @throws if a session with this id is already in the store.
*/
enter(session: Session): () => void {
if (this.store.has(session.id)) throw new Error(`session "${session.id}" already exists`)
enter(session: Session, reservation?: SessionRegistrationReservation): () => void {
const id = session.id
if (typeof id !== 'string') throw new TypeError('session id must be a string')
const held = this.reservations.get(id)
if (reservation === undefined) {
if (held !== undefined) throw new Error(`session "${id}" is reserved for unpublished creation`)
} else if (reservation.id !== id || held !== reservation
|| this.reservedSessions.get(reservation) !== session) {
throw new Error(`session "${id}" registration reservation does not own this prepared session`)
}
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`)
// The carrier is decided HERE, once, from the ENTERING context's scope tag
// (`this.ctx` is the caller's context — the tracker mechanism): every
// session/created|event|flush dispatch for this session uses it, so the
@@ -720,24 +868,65 @@ export class SessionStore extends Service {
const carrier = scopeTarget(session, scopeOf(this.ctx))
this.carriers.set(session, carrier)
const emitCtx = this.ctx
session.onAppend = (event) => { emitCtx.emit(carrier, 'session/event', session, event) }
this.store.set(session.id, session)
appendObservers.set(session, (event) => { emitCtx.emit(carrier, 'session/event', session, event) })
this.acceptedIds.set(session, id)
this.store.set(id, session)
let entered = true
return () => {
if (!entered) return
entered = false
session.onAppend = undefined
const wasAnnounced = this.announced.delete(session)
appendObservers.delete(session)
this.acceptedIds.delete(session)
this.carriers.delete(session)
this.store.delete(session.id)
this.store.delete(id)
if (wasAnnounced) this.emitDisposed(session, carrier, id)
}
}
/** Emit `session/created` for an {@link enter}ed session (with the carrier
* {@link enter} captured). Separate from {@link enter} so the caller can
* yield the detach disposer first (rollback safety — see {@link enter}).
* @param session - the entered session to announce to listeners. */
/** Emit `session/created` exactly once for an {@link enter}ed session (with
* the carrier {@link enter} captured). Separate from {@link enter} so the
* caller can yield the detach disposer first (rollback safety — see
* {@link enter}).
* @param session - the entered session to announce to listeners.
* @throws if the session is not live or its announcement already began,
* including a reentrant call from a creation listener. */
announce(session: Session): void {
this.ctx.emit(this.liveCarrierFor(session), 'session/created', session)
const carrier = this.liveCarrierFor(session)
if (this.announced.has(session)) {
throw new Error(`session "${session.id}" was already announced`)
}
// Mark before emit: Cordis emit may deliver to earlier listeners and then
// 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]
for (const callback of this.ctx.events.dispatch('emit', args)) {
// 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)
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`session "${session.id}": session/created listener rejected: ${renderThrown(error)}`)
})
}
}
/** 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)}`)
}
}
}
/**
@@ -756,8 +945,9 @@ export class SessionStore extends Service {
/** Return the exact live session's carrier; detached/prepared objects reject. */
private liveCarrierFor(session: Session): Scoped<Session> {
if (this.store.get(session.id) !== session) {
throw new Error(`session "${session.id}" is not live in this store`)
const id = this.acceptedIds.get(session)
if (id === undefined || this.store.get(id) !== session) {
throw new Error(`session "${id ?? session.id}" is not live in this store`)
}
const carrier = this.carriers.get(session)
// enter() installs store + carrier in one synchronous sequence; a live
@@ -765,7 +955,7 @@ export class SessionStore extends Service {
// to subject-less dispatch (that would silently cross scope boundaries).
/* v8 ignore next -- enter installs store and carrier in one synchronous sequence */
if (carrier === undefined) {
throw new Error(`session "${session.id}" has no dispatch carrier`)
throw new Error(`session "${id}" has no dispatch carrier`)
}
return carrier
}

View File

@@ -60,6 +60,23 @@ describe('session dispatch carriers', () => {
bare.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(heard).toEqual(['global:turn/start'])
})
it('reuses the captured owner carrier for the paired disposal notification', async () => {
const ctx = await mount()
const owner = await mintScope(ctx, 'owner')
const other = await mintScope(ctx, 'other')
const heard: string[] = []
ctx.on('session/disposed', (session) => { heard.push(`global:${session.id}`) })
owner.ctx.on('session/disposed', (session) => { heard.push(`owner:${session.id}`) })
other.ctx.on('session/disposed', (session) => { heard.push(`other:${session.id}`) })
const session = owner.ctx.sessions.prepare()
const detach = owner.ctx.sessions.enter(session)
owner.ctx.sessions.announce(session)
detach()
expect(heard).toEqual([`global:${session.id}`, `owner:${session.id}`])
})
})
describe('sessions.flush()', () => {

View File

@@ -578,6 +578,17 @@ describe('Session', () => {
expect(session.header).not.toBe(input)
expect(Object.isFrozen(session.header)).toBe(true)
expect(Reflect.set(session.header, 'cwd', '/published-mutated')).toBe(false)
expect(Reflect.set(session, 'id', SessionId('redirected'))).toBe(false)
expect(Reflect.set(session, 'header', input)).toBe(false)
expect(Object.getOwnPropertyDescriptor(session, 'id')).toMatchObject({
configurable: false,
writable: false,
})
expect(Object.getOwnPropertyDescriptor(session, 'header')).toMatchObject({
configurable: false,
writable: false,
})
expect(session.id).toBe('header-owned')
expect(session.header.cwd).toBe('/accepted')
})
@@ -691,6 +702,10 @@ describe('SessionStore', () => {
const session = ctx.sessions.create()
expect(created).toEqual([session])
// The store-owned append observer is 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)
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
expect(events).toHaveLength(1)
expect(events[0]![0]).toBe(session)
@@ -746,6 +761,114 @@ describe('SessionStore', () => {
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
})
it('captures the accepted id once and prevents simultaneous attachment to two stores', async () => {
const firstCtx = new Context()
const secondCtx = new Context()
await firstCtx.plugin(SessionStore)
await secondCtx.plugin(SessionStore)
const session = new Session(SessionId('owned-key'))
const detachFirst = firstCtx.sessions.enter(session)
expect(Reflect.set(session, 'id', SessionId('redirected'))).toBe(false)
expect(() => secondCtx.sessions.enter(session)).toThrow(/already attached to a store/)
expect(firstCtx.sessions.get(SessionId('owned-key'))).toBe(session)
detachFirst()
expect(firstCtx.sessions.get(SessionId('owned-key'))).toBeUndefined()
const detachSecond = secondCtx.sessions.enter(session)
expect(secondCtx.sessions.get(SessionId('owned-key'))).toBe(session)
detachSecond()
expect(() => firstCtx.sessions.enter({ id: 42 } as unknown as Session)).toThrow(/id must be a string/)
})
it('uses an opaque one-session reservation to gate unpublished factory insertion', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const held = ctx.sessions.reserve(SessionId('held-session'))
expect(() => ctx.sessions.reserve(SessionId('held-session'))).toThrow(/already exists or is reserved/)
expect(() => ctx.sessions.prepare(SessionId('held-session'))).toThrow(/reserved for unpublished creation/)
expect(() => ctx.sessions.create(SessionId('held-session'))).toThrow(/reserved for unpublished creation/)
const session = held.prepare({ meta: { cwd: '/held' } })
expect(() => held.prepare()).toThrow(/already prepared/)
expect(() => ctx.sessions.enter(session)).toThrow(/reserved for unpublished creation/)
const other = ctx.sessions.reserve(SessionId('other-session'))
expect(() => ctx.sessions.enter(session, other)).toThrow(/does not own this prepared session/)
expect(() => ctx.sessions.enter(new Session(SessionId('held-session')), held))
.toThrow(/does not own this prepared session/)
const detach = ctx.sessions.enter(session, held)
ctx.sessions.announce(session)
held.release()
held.release()
expect(ctx.sessions.get(SessionId('held-session'))).toBe(session)
expect(() => ctx.sessions.reserve(SessionId('held-session'))).toThrow(/already exists or is reserved/)
detach()
other.release()
const expired = ctx.sessions.reserve(SessionId('expired-session'))
expired.release()
expect(() => expired.prepare()).toThrow(/no longer active/)
expect(() => ctx.sessions.enter(new Session(SessionId('expired-session')), expired))
.toThrow(/does not own this prepared session/)
expect(() => ctx.sessions.reserve(42 as unknown as SessionId)).toThrow(/id must be a string/)
expect(() => ctx.sessions.prepare(42 as unknown as SessionId)).toThrow(/id must be a string/)
// Auto-generated ids skip unpublished reservations just as they skip live
// store entries; no hidden collision can be published later.
const firstAuto = ctx.sessions.reserve(SessionId('session-1'))
expect(ctx.sessions.prepare().id).toBe('session-2')
firstAuto.release()
})
it('owns reservations by the calling fiber and rolls back failed ownership registration', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
let held!: import('@deepseek-ai/dsh-session').SessionRegistrationReservation
let scopedSessions!: SessionStore
const owner = await ctx.plugin(Object.assign((inner: Context) => {
scopedSessions = inner.sessions
held = inner.sessions.reserve(SessionId('fiber-held'))
}, { inject: ['sessions'] }))
expect(() => ctx.sessions.reserve(SessionId('fiber-held'))).toThrow(/already exists or is reserved/)
await owner.dispose()
const reused = ctx.sessions.reserve(SessionId('fiber-held'))
reused.release()
held.release() // idempotent after the automatic owner-disposal release
expect(() => scopedSessions.reserve(SessionId('inactive-owner'))).toThrow(/inactive context/)
const recovered = ctx.sessions.reserve(SessionId('inactive-owner'))
recovered.release()
})
it('rejects direct and reentrant repeat announcements to preserve one lifecycle pair', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
let created = 0
let disposed = 0
let reentrantError = ''
ctx.on('session/created', (session) => {
created += 1
try {
ctx.sessions.announce(session)
} catch (error: unknown) {
reentrantError = String(error)
}
})
ctx.on('session/disposed', () => { disposed += 1 })
const session = ctx.sessions.prepare(SessionId('once'))
const detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
expect(reentrantError).toMatch(/already announced/)
expect(() => { ctx.sessions.announce(session) }).toThrow(/already announced/)
detach()
expect({ created, disposed }).toEqual({ created: 1, disposed: 1 })
})
it('synthesizes a minimal current-version header for a bare-created session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -864,11 +987,13 @@ describe('SessionStore', () => {
expect(observed).toBe(0)
})
it('rolls back the session (and onAppend) when a session/created listener throws (P1-1)', async () => {
it('pairs a partial session/created announcement with disposal during rollback', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
let threw = false
const disposed: Session[] = []
ctx.on('session/disposed', (session) => { disposed.push(session) })
ctx.on('session/created', () => {
if (!threw) { threw = true; throw new Error('boom created listener') }
})
@@ -876,9 +1001,10 @@ describe('SessionStore', () => {
// The throwing emit must roll the store entry back, not leak it.
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('boom created listener')
expect(ctx.sessions.get(SessionId('fixed'))).toBeUndefined() // rolled back, not leaked
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 onAppend is correctly wired (events observable).
// not wedged) and its store-owned observer is correctly wired (events observable).
const events: SessionEvent[] = []
ctx.on('session/event', (_session, event) => void events.push(event))
const session = ctx.sessions.create(SessionId('fixed'))
@@ -886,6 +1012,59 @@ describe('SessionStore', () => {
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
expect(events).toHaveLength(1)
})
it('observes async session/created rejection without rolling back or starving peers', 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: string[] = []
ctx.on('session/created', () => Promise.reject(new Error('late creation failure')) as never)
ctx.on('session/created', (session) => { heard.push(session.id) })
const session = ctx.sessions.create(SessionId('async-created'))
await Promise.resolve()
await Promise.resolve()
expect(ctx.sessions.get(session.id)).toBe(session)
expect(heard).toEqual(['async-created'])
expect(warnings).toEqual([
'session "async-created": session/created listener rejected: Error: late creation failure',
])
})
it('contains synchronous and async session/disposed listener failures per observer', 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 hostile = { [Symbol.toPrimitive]() { throw new Error('cannot stringify') } }
const printable = { toString: () => 'printable failure' }
const heard: string[] = []
ctx.on('session/disposed', () => { throw hostile })
ctx.on('session/disposed', () => Promise.reject(new Error('async disposed')) as never)
ctx.on('session/disposed', () => { throw printable })
ctx.on('session/disposed', (session) => { heard.push(session.id) })
const unannounced = ctx.sessions.prepare(SessionId('never-announced'))
const detachUnannounced = ctx.sessions.enter(unannounced)
detachUnannounced()
expect(heard).toEqual([])
const announced = ctx.sessions.prepare(SessionId('contained-disposal'))
const detach = ctx.sessions.enter(announced)
ctx.sessions.announce(announced)
expect(() => { detach() }).not.toThrow()
await Promise.resolve()
await Promise.resolve()
expect(heard).toEqual(['contained-disposal'])
expect(warnings).toEqual([
'session "contained-disposal": session/disposed listener threw: <unrenderable thrown value>',
'session "contained-disposal": session/disposed listener threw: printable failure',
'session "contained-disposal": session/disposed listener rejected: Error: async disposed',
])
})
})
describe('todo/write event', () => {