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

@@ -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
}