refactor(core): simplify scoped agent lifecycles
This commit is contained in:
@@ -8,7 +8,7 @@ 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.create(id?: SessionId, options?: { seed?: readonly SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. The persistence/replay seed and resulting header are validated, detached, and deep-frozen at this durable boundary. The store fills `version`/`id` and defaults `createdAt` to now; a persisted reconstruction supplies the original `createdAt` and `seedLength`. 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`). 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`
|
||||
@@ -18,28 +18,27 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
|
||||
|
||||
`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 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.prepare(id?, options?): Session` — validate durable seed/header data and construct the `Session` WITHOUT entering it into the store. Same options as `create`.
|
||||
- `ctx.sessions.enter(session): () => void` — perform the authoritative ID collision check, install append publication state, and insert the exact session without announcing it. Returns an idempotent detach bound to the captured entry object, so a stale disposer cannot remove a later same-ID replacement. Concurrent same-ID preparation is allowed; only one final entry succeeds.
|
||||
- `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. 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.
|
||||
The store pairs announced creation with disposal, publishes each append, and provides an awaited durability checkpoint. Before the log push it resolves the scoped `session/event` callback list. The push is the commit point; callback throws or returned-promise rejections are logged and contained per observer. A committed append therefore returns normally, later observers still run, and detach waits until publication unwinds. 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).
|
||||
|
||||
### 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. 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.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. At this durable boundary, data and surface metadata are lossless-JSON snapshotted and deep-frozen. For an attached session, a reentrant append during dispatch/observer publication rejects, and detach waits for that publication to unwind. Callbacks resolve before the log push; the push is the commit point, after which each observer failure is contained independently. Runtime surface validation covers widened unions and raw seed/load logs.
|
||||
- `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 per call over shared, deep-frozen `Message` objects. Each projection reuses the already deep-frozen content in its durable log event, so no second deep clone is needed and a consumer still cannot mutate logged data. 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: a fresh message wrapper that reuses the event's already frozen content, or `null` when the event 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` — `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.
|
||||
- `session.seq`, `session.id` — current sequence and readonly typed identity.
|
||||
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`.
|
||||
|
||||
### Lossless JSON utilities
|
||||
|
||||
|
||||
@@ -121,106 +121,40 @@ function renderTagged(tag: string, content: ContentBlock[], source: MessageSourc
|
||||
]
|
||||
}
|
||||
|
||||
/** Reject a record shell that cloning or spreading would otherwise sanitize. */
|
||||
function assertPlainRecord(value: unknown, label: string): asserts value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
throw new Error(`${label} is not a plain JSON record`)
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value) as unknown
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new Error(`${label} is not a plain JSON record`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Capture and validate the caller-owned fields that become a session header. */
|
||||
function snapshotSessionMeta(source: CreateSessionOptions['meta']): NonNullable<CreateSessionOptions['meta']> {
|
||||
if (source === undefined) return {}
|
||||
assertPlainRecord(source, 'session metadata')
|
||||
|
||||
// Read each accepted field exactly once. The metadata vocabulary is scalar,
|
||||
// so this plain record is already detached from the caller; cloning the
|
||||
// caller's shell first would erase a class prototype before validation.
|
||||
const cwd = source.cwd
|
||||
const parentSession = source.parentSession
|
||||
const createdAt = source.createdAt
|
||||
const seedLength = source.seedLength
|
||||
const accepted = {
|
||||
...cwd !== undefined ? { cwd } : {},
|
||||
...parentSession !== undefined ? { parentSession } : {},
|
||||
...createdAt !== undefined ? { createdAt } : {},
|
||||
...seedLength !== undefined ? { seedLength } : {},
|
||||
}
|
||||
const snapshot = snapshotJsonValue(accepted)
|
||||
if (snapshot === undefined) throw new Error('session metadata is not losslessly JSON-serializable')
|
||||
if (snapshot.cwd !== undefined) {
|
||||
if (typeof snapshot.cwd !== 'string') throw new Error('session cwd must be a string')
|
||||
if (!isAbsolute(snapshot.cwd)) {
|
||||
throw new Error(`session cwd must be an absolute path, got "${snapshot.cwd}"`)
|
||||
}
|
||||
}
|
||||
if (snapshot.parentSession !== undefined && typeof snapshot.parentSession !== 'string') {
|
||||
throw new Error('session parentSession must be a string')
|
||||
}
|
||||
if (snapshot.createdAt !== undefined
|
||||
&& (typeof snapshot.createdAt !== 'number' || !Number.isFinite(snapshot.createdAt))) {
|
||||
throw new Error('session createdAt must be a finite number')
|
||||
}
|
||||
if (snapshot.seedLength !== undefined
|
||||
&& (typeof snapshot.seedLength !== 'number' || !Number.isSafeInteger(snapshot.seedLength) || snapshot.seedLength < 0)) {
|
||||
throw new Error('session seedLength must be a non-negative safe integer')
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/** Detach, validate, and freeze the creation metadata published by a session. */
|
||||
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
|
||||
const input: SessionHeader = source === undefined
|
||||
const input: unknown = source === undefined
|
||||
? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
|
||||
: source
|
||||
assertPlainRecord(input, 'session header')
|
||||
|
||||
// Capture each property once before validation. A stateful accessor therefore
|
||||
// cannot present one identity or storage location to a check and publish a
|
||||
// different one afterward.
|
||||
const version = input.version
|
||||
const headerId = input.id
|
||||
const createdAt = input.createdAt
|
||||
const cwd = input.cwd
|
||||
const parentSession = input.parentSession
|
||||
const seedLength = input.seedLength
|
||||
const accepted = {
|
||||
version,
|
||||
id: headerId,
|
||||
createdAt,
|
||||
...cwd !== undefined ? { cwd } : {},
|
||||
...parentSession !== undefined ? { parentSession } : {},
|
||||
...seedLength !== undefined ? { seedLength } : {},
|
||||
}
|
||||
const snapshot = snapshotJsonValue(accepted)
|
||||
const snapshot = snapshotJsonValue(input)
|
||||
if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
|
||||
if (snapshot.version !== SESSION_FORMAT_VERSION) {
|
||||
throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(snapshot.version)}`)
|
||||
if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
|
||||
throw new Error('session header is not a plain JSON record')
|
||||
}
|
||||
if (snapshot.id !== id) {
|
||||
throw new Error(`session header id "${String(snapshot.id)}" does not match session id "${id}"`)
|
||||
const record = snapshot as Record<string, unknown>
|
||||
if (record.version !== SESSION_FORMAT_VERSION) {
|
||||
throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`)
|
||||
}
|
||||
if (typeof snapshot.createdAt !== 'number' || !Number.isFinite(snapshot.createdAt)) {
|
||||
if (record.id !== id) {
|
||||
throw new Error(`session header id "${String(record.id)}" does not match session id "${id}"`)
|
||||
}
|
||||
if (typeof record.createdAt !== 'number' || !Number.isFinite(record.createdAt)) {
|
||||
throw new Error('session header createdAt must be a finite number')
|
||||
}
|
||||
if (snapshot.cwd !== undefined) {
|
||||
if (typeof snapshot.cwd !== 'string') throw new Error('session header cwd must be a string')
|
||||
if (!isAbsolute(snapshot.cwd)) {
|
||||
throw new Error(`session header cwd must be an absolute path, got "${snapshot.cwd}"`)
|
||||
if (record.cwd !== undefined) {
|
||||
if (typeof record.cwd !== 'string') throw new Error('session header cwd must be a string')
|
||||
if (!isAbsolute(record.cwd)) {
|
||||
throw new Error(`session header cwd must be an absolute path, got "${record.cwd}"`)
|
||||
}
|
||||
}
|
||||
if (snapshot.parentSession !== undefined && typeof snapshot.parentSession !== 'string') {
|
||||
if (record.parentSession !== undefined && typeof record.parentSession !== 'string') {
|
||||
throw new Error('session header parentSession must be a string')
|
||||
}
|
||||
if (snapshot.seedLength !== undefined
|
||||
&& (typeof snapshot.seedLength !== 'number' || !Number.isSafeInteger(snapshot.seedLength) || snapshot.seedLength < 0)) {
|
||||
if (record.seedLength !== undefined
|
||||
&& (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) {
|
||||
throw new Error('session header seedLength must be a non-negative safe integer')
|
||||
}
|
||||
return deepFreeze(snapshot)
|
||||
return deepFreeze(record as unknown as SessionHeader)
|
||||
}
|
||||
|
||||
/** Validate the runtime shape of surface metadata after its JSON snapshot. */
|
||||
@@ -275,25 +209,6 @@ 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>'
|
||||
}
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
@@ -301,13 +216,6 @@ 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,
|
||||
@@ -320,26 +228,29 @@ function invokeContainedSessionObservers(
|
||||
try {
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
warnContained(ctx, `session "${id}": ${name} listener rejected: ${renderThrown(error)}`)
|
||||
ctx.logger.warn(`session "${id}": ${name} listener rejected: ${String(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
warnContained(ctx, `session "${id}": ${name} listener threw: ${renderThrown(error)}`)
|
||||
ctx.logger.warn(`session "${id}": ${name} listener threw: ${String(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
|
||||
/** All mutable lifecycle state for one exact store entry. */
|
||||
interface SessionEntry {
|
||||
readonly id: SessionId
|
||||
readonly session: Session
|
||||
readonly carrier: Scoped<Session>
|
||||
readonly emitCtx: Context
|
||||
announced: boolean
|
||||
announcing: boolean
|
||||
appending: boolean
|
||||
detachRequested: boolean
|
||||
detach(): void
|
||||
}
|
||||
|
||||
const appendHooks = new WeakMap<Session, SessionAppendHooks>()
|
||||
/** Identity token replaced on every store attachment or detachment. */
|
||||
const attachmentEpochs = new WeakMap<Session, object>()
|
||||
/** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */
|
||||
const attachments = new WeakMap<Session, SessionEntry>()
|
||||
|
||||
/**
|
||||
* An event-sourced session: an append-only log of {@link SessionEvent}s.
|
||||
@@ -349,8 +260,6 @@ const attachmentEpochs = new WeakMap<Session, object>()
|
||||
*/
|
||||
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.
|
||||
@@ -377,7 +286,7 @@ export class Session {
|
||||
*/
|
||||
readonly header: SessionHeader
|
||||
|
||||
constructor(public readonly id: SessionId, seed?: SessionEvent[], header?: SessionHeader) {
|
||||
constructor(public readonly id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
|
||||
if (seed) {
|
||||
// Validate the seed to the SAME invariants `append` enforces, so a
|
||||
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
|
||||
@@ -387,20 +296,9 @@ export class Session {
|
||||
// a bad seed would surface only later as a backend rejection or a silent
|
||||
// divergence between the live log and disk.
|
||||
this.log = Array.from(seed, (source, index) => {
|
||||
// Spreading would erase a class instance's prototype. Reject an exotic
|
||||
// event shell before that normalization can turn it into an apparently
|
||||
// valid plain record; field values are still captured by the one spread
|
||||
// below, so their accessors are not read twice.
|
||||
assertPlainRecord(source, `seed event at index ${index}`)
|
||||
// Read every enumerable event field once. Validation and snapshot
|
||||
// construction must consume this same captured record: a stateful seed
|
||||
// index or event getter cannot present one record to the checks and
|
||||
// another to the durable log.
|
||||
const event = { ...source }
|
||||
// Materialize the complete accepted record in one recursive pass. A
|
||||
// validate-then-structuredClone sequence would reread nested getters and
|
||||
// could sanitize a class instance returned only to the clone.
|
||||
const snapshot = snapshotJsonValue(event)
|
||||
// The seed is a persistence/replay boundary: validate and detach the
|
||||
// complete event in one lossless-JSON pass.
|
||||
const snapshot = snapshotJsonValue(source)
|
||||
if (snapshot === undefined) {
|
||||
throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`)
|
||||
}
|
||||
@@ -424,14 +322,6 @@ 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. */
|
||||
@@ -490,88 +380,53 @@ export class Session {
|
||||
data: SessionEventMap[T],
|
||||
...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
|
||||
): SessionEvent<T> {
|
||||
if (typeof type !== 'string') {
|
||||
throw new TypeError('session event type must be a string')
|
||||
const surfaceOpts: SurfaceIntent | undefined = opts[0]
|
||||
const surfaceMetadata = {
|
||||
...surfaceOpts?.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: surfaceOpts.sourceEventSeqs },
|
||||
...surfaceOpts?.surfaceOp === undefined ? {} : { surfaceOp: surfaceOpts.surfaceOp },
|
||||
}
|
||||
if (this.appendInProgress) {
|
||||
throw new Error('session append cannot reenter while another append is being accepted or published')
|
||||
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
|
||||
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 entry = attachments.get(this)
|
||||
if (entry?.appending) {
|
||||
throw new Error('session append cannot reenter while another append is being published')
|
||||
}
|
||||
if (entry !== undefined) entry.appending = 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 = {
|
||||
const event = deepFreeze({
|
||||
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)
|
||||
} as unknown as SessionEvent<T>)
|
||||
let callbacks: SessionCallback[] | undefined
|
||||
const callbackArgs: unknown[] = [this, event]
|
||||
if (entry !== undefined) {
|
||||
callbacks = collectSessionCallbacks(entry.emitCtx, [entry.carrier, 'session/event', ...callbackArgs])
|
||||
}
|
||||
this.log.push(event as SessionEvent)
|
||||
this.eventsSnapshot = undefined
|
||||
publish?.()
|
||||
return acceptedEvent
|
||||
if (callbacks !== undefined && entry !== undefined) {
|
||||
invokeContainedSessionObservers(entry.emitCtx, 'session/event', entry.id, callbackArgs, callbacks)
|
||||
}
|
||||
return event
|
||||
} finally {
|
||||
try {
|
||||
hooks?.end()
|
||||
} finally {
|
||||
this.appendInProgress = false
|
||||
if (entry !== undefined) {
|
||||
entry.appending = false
|
||||
if (entry.detachRequested && !entry.announcing) entry.detach()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -621,10 +476,9 @@ export class Session {
|
||||
* call costs O(new nodes), and a surface rewrite (a `replace`;
|
||||
* {@link SurfaceManager.replaceGeneration}) rebuilds. The returned array is
|
||||
* a fresh snapshot per call (later appends never grow an array a caller
|
||||
* already holds); the `Message` objects in it are SHARED and **deep-frozen**
|
||||
* — cloned once off the log at projection time, so consumers can never
|
||||
* mutate logged data, and mutation attempts throw instead of silently
|
||||
* diverging replay from history.
|
||||
* already holds); the `Message` objects in it are SHARED and **deep-frozen**.
|
||||
* Their content reuses the already frozen durable event data, so the cache
|
||||
* needs no second deep clone and consumers still cannot mutate the log.
|
||||
* @returns a fresh array of the shared, frozen derived history.
|
||||
*/
|
||||
deriveMessages(): Message[] {
|
||||
@@ -656,9 +510,10 @@ export class Session {
|
||||
* The per-node pure function {@link deriveMessages} folds over the surface;
|
||||
* an external reconstructor (or the dev invariant) folds the same function
|
||||
* over a log prefix's surface to rebuild the exact messages any request was
|
||||
* built from (the reconstructability RFC). The returned `content` is
|
||||
* deep-cloned off the logged event: the log is append-only by contract, so
|
||||
* no live reference to logged data leaves this boundary.
|
||||
* built from (the reconstructability RFC). The returned message wrapper is
|
||||
* fresh; its content reuses the logged event's already deep-frozen durable
|
||||
* data, so changing the wrapper cannot rewrite the log and changing content
|
||||
* throws.
|
||||
* @param event - the event to project.
|
||||
* @returns the derived message, or null when the event produces none.
|
||||
*/
|
||||
@@ -669,29 +524,29 @@ export class Session {
|
||||
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
return { role: 'user', content: structuredClone(event.data.content) }
|
||||
return { role: 'user', content: event.data.content }
|
||||
}
|
||||
case 'assistant/message': {
|
||||
// Skip an empty-content assistant/message: it exists only to host a
|
||||
// max-tokens step's usage and must not inject a content-less assistant
|
||||
// turn into the provider transcript.
|
||||
if (event.data.content.length === 0) return null
|
||||
return { role: 'assistant', content: structuredClone(event.data.content) }
|
||||
return { role: 'assistant', content: event.data.content }
|
||||
}
|
||||
case 'tool/result': {
|
||||
const { callId, content, isError } = event.data
|
||||
return {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool-result', toolCallId: callId, content: structuredClone(content), isError }],
|
||||
content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
|
||||
}
|
||||
}
|
||||
case 'context/message': {
|
||||
const { content, source } = event.data
|
||||
return { role: 'user', content: renderTagged('context', structuredClone(content), source) }
|
||||
return { role: 'user', content: renderTagged('context', content, source) }
|
||||
}
|
||||
case 'steering/message': {
|
||||
const { content, source } = event.data
|
||||
return { role: 'user', content: renderTagged('steering', structuredClone(content), source) }
|
||||
return { role: 'user', content: renderTagged('steering', content, source) }
|
||||
}
|
||||
default:
|
||||
// A non-surface event (boundary, chunk, log-only record) projects to
|
||||
@@ -727,31 +582,6 @@ 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. This
|
||||
* function is that exact Cordis effect disposer, so an ordered lifecycle may
|
||||
* yield it by identity and place release after quiescence.
|
||||
* @returns nothing.
|
||||
*/
|
||||
release(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory session store (`ctx.sessions`).
|
||||
*
|
||||
@@ -759,80 +589,13 @@ export interface SessionRegistrationReservation {
|
||||
* subscribe to `session/event` and flush on `session/flush` / dispose.
|
||||
*/
|
||||
export class SessionStore extends Service {
|
||||
private store = new Map<SessionId, Session>()
|
||||
/** Ids claimed across caller-code boundaries before their exact entry commits. */
|
||||
private enteringIds = new Set<SessionId>()
|
||||
/** 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>()
|
||||
/** Entries currently dispatching `session/created`; detach waits for dispatch to unwind. */
|
||||
private announcing = new WeakSet<Session>()
|
||||
/** 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>()
|
||||
/** 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
|
||||
* `agent.ctx` ⇒ its events dispatch in that agent's scope; a bare session ⇒
|
||||
* subject-less carrier). WeakMap so a detached session drops its carrier
|
||||
* with the entry.
|
||||
*/
|
||||
private carriers = new WeakMap<Session, Scoped<Session>>()
|
||||
private store = new Map<SessionId, SessionEntry>()
|
||||
private counter = 0
|
||||
|
||||
constructor(ctx: Context) {
|
||||
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) || this.enteringIds.has(id)) {
|
||||
throw new Error(`session "${id}" already exists or is reserved`)
|
||||
}
|
||||
let active = true
|
||||
let prepared = false
|
||||
const rawRelease = (): void => {
|
||||
active = false
|
||||
this.reservedSessions.delete(reservation)
|
||||
this.reservations.delete(id)
|
||||
}
|
||||
// `release` is the exact effect disposer, so an ordered composite can
|
||||
// adopt the automatic owner cleanup instead of racing it as a sibling.
|
||||
const release = this.ctx.effect(() => rawRelease, `sessions.reserve(${id})`)
|
||||
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,
|
||||
})
|
||||
this.reservations.set(id, reservation)
|
||||
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`
|
||||
@@ -844,8 +607,8 @@ export class SessionStore extends Service {
|
||||
* 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 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`).
|
||||
* {@link prepare} + {@link enter} + {@link announce} (see
|
||||
* `dsh-agent-loop`'s creation transaction).
|
||||
*
|
||||
* @param id - the session id; omitted, the store mints `session-<n>`.
|
||||
* @param options - seed events and/or creation metadata for the header.
|
||||
@@ -884,40 +647,23 @@ export class SessionStore extends Service {
|
||||
* non-absolute path.
|
||||
*/
|
||||
prepare(id?: SessionId, options?: CreateSessionOptions): Session {
|
||||
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))
|
||||
while (this.store.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)
|
||||
const cwd = meta.cwd
|
||||
const parentSession = meta.parentSession
|
||||
const seedLength = meta.seedLength
|
||||
const meta = options?.meta
|
||||
const header: SessionHeader = {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: sessionId,
|
||||
createdAt: meta.createdAt ?? Date.now(),
|
||||
...cwd !== undefined ? { cwd } : {},
|
||||
...parentSession !== undefined ? { parentSession } : {},
|
||||
...seedLength !== undefined ? { seedLength } : {},
|
||||
createdAt: meta?.createdAt ?? Date.now(),
|
||||
...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
|
||||
...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
|
||||
...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
|
||||
}
|
||||
return new Session(sessionId, seed, header)
|
||||
}
|
||||
@@ -939,77 +685,31 @@ export class SessionStore extends Service {
|
||||
* assume that.
|
||||
*
|
||||
* @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 (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.
|
||||
*/
|
||||
enter(session: Session, reservation?: SessionRegistrationReservation): () => void {
|
||||
enter(session: Session): () => 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) || this.enteringIds.has(id)) {
|
||||
throw new Error(`session "${id}" already exists`)
|
||||
}
|
||||
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
|
||||
// session/created|event|flush dispatch for this session uses it, so the
|
||||
// session's whole event feed is scope-filtered consistently. The base is
|
||||
// the session itself (scoped listeners' `this` is the session).
|
||||
let carrier: Scoped<Session>
|
||||
try {
|
||||
carrier = scopeTarget(session, scopeOf(this.ctx))
|
||||
} finally {
|
||||
this.enteringIds.delete(id)
|
||||
}
|
||||
const currentReservation = this.reservations.get(id)
|
||||
if (reservation === undefined) {
|
||||
/* v8 ignore next 2 -- reserve() rejects enteringIds, so carrier
|
||||
* construction cannot install a new same-id reservation */
|
||||
if (currentReservation !== undefined) {
|
||||
throw new Error(`session "${id}" is reserved for unpublished creation`)
|
||||
}
|
||||
} else if (currentReservation !== reservation
|
||||
|| this.reservedSessions.get(reservation) !== session) {
|
||||
throw new Error(`session "${id}" registration reservation does not own this prepared session`)
|
||||
}
|
||||
/* v8 ignore next 1 -- enteringIds prevents a same-store commit during carrier construction */
|
||||
const carrier = scopeTarget(session, scopeOf(this.ctx))
|
||||
// This is the authoritative collision boundary after arbitrary unpublished
|
||||
// preparation. Only one exact same-id transaction can publish.
|
||||
if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
|
||||
if (appendHooks.has(session)) throw new Error(`session "${id}" is already attached to a store`)
|
||||
this.carriers.set(session, carrier)
|
||||
const emitCtx = this.ctx
|
||||
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)
|
||||
if (attachments.has(session)) throw new Error(`session "${id}" is already attached to a store`)
|
||||
const entry: SessionEntry = {
|
||||
id,
|
||||
session,
|
||||
carrier,
|
||||
emitCtx: this.ctx,
|
||||
announced: false,
|
||||
announcing: false,
|
||||
appending: false,
|
||||
detachRequested: false,
|
||||
detach: () => { this.detachEntered(entry) },
|
||||
}
|
||||
this.store.set(id, entry)
|
||||
attachments.set(session, entry)
|
||||
let entered = true
|
||||
const detach = (): void => {
|
||||
if (!entered) return
|
||||
@@ -1017,30 +717,24 @@ export class SessionStore extends Service {
|
||||
// A lifecycle listener may own the advanced detach capability. Keep the
|
||||
// entry and its publication hooks live until synchronous creation or append
|
||||
// publication unwinds, then publish the paired disposal edge.
|
||||
if (this.announcing.has(session) || this.appending.has(session)) {
|
||||
this.pendingDetach.add(session)
|
||||
if (entry.announcing || entry.appending) {
|
||||
entry.detachRequested = true
|
||||
return
|
||||
}
|
||||
this.detachEntered(session, id, carrier)
|
||||
entry.detach()
|
||||
}
|
||||
return detach
|
||||
}
|
||||
|
||||
/** Remove one exact entered session and emit its paired disposal when announced. */
|
||||
private detachEntered(session: Session, id: SessionId, carrier: Scoped<Session>): void {
|
||||
this.pendingDetach.delete(session)
|
||||
private detachEntered(entry: SessionEntry): void {
|
||||
entry.detachRequested = false
|
||||
// A stale capability cannot remove observers or storage belonging to a
|
||||
// later same-id lifecycle.
|
||||
/* v8 ignore next 1 -- the commit claim makes replacement impossible; this
|
||||
* 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)
|
||||
appendHooks.delete(session)
|
||||
attachmentEpochs.set(session, {})
|
||||
this.acceptedIds.delete(session)
|
||||
this.carriers.delete(session)
|
||||
this.store.delete(id)
|
||||
if (wasAnnounced) this.emitDisposed(session, carrier, id)
|
||||
if (this.store.get(entry.id) !== entry) return
|
||||
this.store.delete(entry.id)
|
||||
attachments.delete(entry.session)
|
||||
if (entry.announced) this.emitDisposed(entry)
|
||||
}
|
||||
|
||||
/** Emit `session/created` exactly once for an {@link enter}ed session (with
|
||||
@@ -1051,20 +745,18 @@ export class SessionStore extends Service {
|
||||
* @throws if the session is not live or its announcement already began,
|
||||
* including a reentrant call from a creation listener. */
|
||||
announce(session: Session): void {
|
||||
const { carrier, id } = this.liveEntryFor(session)
|
||||
if (this.announced.has(session)) {
|
||||
throw new Error(`session "${id}" was already announced`)
|
||||
const entry = this.liveEntryFor(session)
|
||||
if (entry.announced || entry.announcing) {
|
||||
throw new Error(`session "${entry.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 dispatchArgs: unknown[] = [carrier, 'session/created', session]
|
||||
entry.announced = true
|
||||
const callbackArgs: unknown[] = [session]
|
||||
this.announcing.add(session)
|
||||
entry.announcing = true
|
||||
try {
|
||||
const callbacks = collectSessionCallbacks(this.ctx, dispatchArgs)
|
||||
assertDispatchTuple('session/created', dispatchArgs, callbackArgs)
|
||||
const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/created', session])
|
||||
for (const callback of callbacks) {
|
||||
// Synchronous throws intentionally propagate and veto publication; the
|
||||
// yielded detach then emits the paired disposal edge. An async function
|
||||
@@ -1073,26 +765,23 @@ export class SessionStore extends Service {
|
||||
// of becoming unhandled.
|
||||
const returned: unknown = callback(...callbackArgs)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
warnContained(this.ctx, `session "${id}": session/created listener rejected: ${renderThrown(error)}`)
|
||||
this.ctx.logger.warn(`session "${entry.id}": session/created listener rejected: ${String(error)}`)
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
this.announcing.delete(session)
|
||||
if (this.pendingDetach.has(session) && !this.appending.has(session)) {
|
||||
this.detachEntered(session, id, carrier)
|
||||
}
|
||||
entry.announcing = false
|
||||
if (entry.detachRequested && !entry.appending) entry.detach()
|
||||
}
|
||||
}
|
||||
|
||||
/** Emit the paired teardown notification with per-listener containment. */
|
||||
private emitDisposed(session: Session, carrier: Scoped<Session>, id: SessionId): void {
|
||||
const dispatchArgs: unknown[] = [carrier, 'session/disposed', session]
|
||||
const callbackArgs: unknown[] = [session]
|
||||
private emitDisposed(entry: SessionEntry): void {
|
||||
const callbackArgs: unknown[] = [entry.session]
|
||||
try {
|
||||
const callbacks = collectSessionCallbacks(this.ctx, dispatchArgs)
|
||||
invokeContainedSessionObservers(this.ctx, 'session/disposed', id, callbackArgs, callbacks)
|
||||
const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/disposed', entry.session])
|
||||
invokeContainedSessionObservers(this.ctx, 'session/disposed', entry.id, callbackArgs, callbacks)
|
||||
} catch (error: unknown) {
|
||||
warnContained(this.ctx, `session "${id}": session/disposed dispatch threw: ${renderThrown(error)}`)
|
||||
this.ctx.logger.warn(`session "${entry.id}": session/disposed dispatch threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1109,10 +798,8 @@ export class SessionStore extends Service {
|
||||
*/
|
||||
async flush(session: Session): Promise<void> {
|
||||
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 callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session])
|
||||
const results = await Promise.allSettled(callbacks.map((callback) => {
|
||||
try {
|
||||
return callback(...callbackArgs)
|
||||
@@ -1127,21 +814,13 @@ export class SessionStore extends Service {
|
||||
if (failure !== undefined) throw failure.reason
|
||||
}
|
||||
|
||||
/** Return the exact live session's accepted id and carrier; detached/prepared objects reject. */
|
||||
private liveEntryFor(session: Session): { id: SessionId; carrier: Scoped<Session> } {
|
||||
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`)
|
||||
/** Return the exact live entry; detached/prepared objects reject. */
|
||||
private liveEntryFor(session: Session): SessionEntry {
|
||||
const entry = attachments.get(session)
|
||||
if (entry === undefined || this.store.get(entry.id) !== entry) {
|
||||
throw new Error(`session "${session.id}" is not live in this store`)
|
||||
}
|
||||
const carrier = this.carriers.get(session)
|
||||
// enter() installs store + carrier in one synchronous sequence; a live
|
||||
// session without one is an internal invariant violation, never fallback
|
||||
// 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 "${id}" has no dispatch carrier`)
|
||||
}
|
||||
return { id, carrier }
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1150,7 +829,7 @@ export class SessionStore extends Service {
|
||||
* @returns the session, or undefined when no live session has that id.
|
||||
*/
|
||||
get(id: SessionId): Session | undefined {
|
||||
return this.store.get(id)
|
||||
return this.store.get(id)?.session
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1158,7 +837,7 @@ export class SessionStore extends Service {
|
||||
* @returns a fresh array; mutating it does not affect the store.
|
||||
*/
|
||||
list(): Session[] {
|
||||
return [...this.store.values()]
|
||||
return [...this.store.values()].map(entry => entry.session)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1228,7 +907,7 @@ export class SessionStore extends Service {
|
||||
)
|
||||
}
|
||||
|
||||
return events.slice(0, boundary + 1).map(event => structuredClone(event))
|
||||
return events.slice(0, boundary + 1)
|
||||
}
|
||||
|
||||
private _resolveForkSource(source: SessionForkSource): Session {
|
||||
|
||||
@@ -48,15 +48,15 @@ export interface SessionHeader {
|
||||
* session is created. A persistence backend rejects any other version on load
|
||||
* (no migration — see the constant).
|
||||
*/
|
||||
version: number
|
||||
readonly version: number
|
||||
/** The session's id (mirrors the {@link Session}'s id). */
|
||||
id: SessionId
|
||||
readonly id: SessionId
|
||||
/** Unix epoch milliseconds when the session was created. */
|
||||
createdAt: number
|
||||
readonly createdAt: number
|
||||
/** Absolute working directory the session was created in (if any). */
|
||||
cwd?: string
|
||||
readonly cwd?: string
|
||||
/** The session this one was forked from (seed lineage), if any. */
|
||||
parentSession?: SessionId
|
||||
readonly parentSession?: SessionId
|
||||
/**
|
||||
* How many leading events were INHERITED via a seed rather than produced by
|
||||
* this session — the seed boundary. Set when a fork seeds a child with a
|
||||
@@ -66,7 +66,7 @@ export interface SessionHeader {
|
||||
* harness can skip the inherited prefix when deriving the child's OWN script
|
||||
* (the seeded events are the parent's, not this child's model calls).
|
||||
*/
|
||||
seedLength?: number
|
||||
readonly seedLength?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +76,7 @@ export interface SessionHeader {
|
||||
*/
|
||||
export interface CreateSessionOptions {
|
||||
/** Events to seed the new session with (replay/fork). */
|
||||
seed?: SessionEvent[]
|
||||
readonly seed?: readonly SessionEvent[]
|
||||
/**
|
||||
* Creation metadata. The store reads this plain record and each accepted
|
||||
* field once, then fills in `version`/`id` and defaults
|
||||
@@ -90,7 +90,12 @@ export interface CreateSessionOptions {
|
||||
* length, not the original boundary — the caller must pass the persisted
|
||||
* boundary back. A fresh fork passes its actual seeded-prefix length.
|
||||
*/
|
||||
meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number }
|
||||
readonly meta?: {
|
||||
readonly cwd?: string
|
||||
readonly parentSession?: SessionId
|
||||
readonly createdAt?: number
|
||||
readonly seedLength?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -89,15 +89,15 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
|
||||
expect(session.deriveEventMessage(event)).toEqual(session.deriveMessages().at(-1))
|
||||
})
|
||||
|
||||
it('clones content off the log: the projection never aliases the logged event', () => {
|
||||
it('reuses the logged event\'s already frozen content', () => {
|
||||
const session = new Session(SessionId('per-event-clone'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const event = session.append('user/message', { content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
const message = session.deriveEventMessage(event)!
|
||||
expect(message.content).not.toBe(event.data.content)
|
||||
// deriveEventMessage returns an unfrozen clone (the cache freezes ITS
|
||||
// copies); mutating it must not reach the log.
|
||||
;(message.content[0] as { text: string }).text = 'mutated'
|
||||
expect(message.content).toBe(event.data.content)
|
||||
expect(Object.isFrozen(message.content)).toBe(true)
|
||||
expect(Object.isFrozen(message.content[0])).toBe(true)
|
||||
expect(() => { (message.content[0] as { text: string }).text = 'mutated' }).toThrow()
|
||||
expect(session.deriveMessages().at(-1)!.content).toEqual([{ type: 'text', text: 'orig' }])
|
||||
})
|
||||
|
||||
|
||||
@@ -154,21 +154,6 @@ 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')
|
||||
|
||||
@@ -129,16 +129,6 @@ describe('Session', () => {
|
||||
expect(session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects a non-string event type without retaining or freezing caller data', () => {
|
||||
const session = new Session(SessionId('invalid-event-type'))
|
||||
const type = { tag: 'caller-owned' }
|
||||
const appendRaw = session.append.bind(session) as unknown as (type: unknown, data: unknown) => SessionEvent
|
||||
|
||||
expect(() => appendRaw(type, {})).toThrow(/event type must be a string/)
|
||||
expect(Object.isFrozen(type)).toBe(false)
|
||||
expect(session.events).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
|
||||
const session = new Session(SessionId('s5b'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
@@ -282,7 +272,7 @@ describe('Session', () => {
|
||||
const seed: SessionEvent[] = [new SeedEvent()]
|
||||
|
||||
expect(() => new Session(SessionId('seed-exotic-shell'), seed))
|
||||
.toThrow(/not a plain JSON record/)
|
||||
.toThrow(/not losslessly JSON-serializable/)
|
||||
})
|
||||
|
||||
it('accepts a null-prototype seed event shell as a plain JSON record', () => {
|
||||
@@ -396,26 +386,6 @@ describe('Session', () => {
|
||||
expect(session.events).toEqual([event])
|
||||
})
|
||||
|
||||
it('reads surface metadata accessors once so a validated marker is logged', () => {
|
||||
const session = new Session(SessionId('surface-intent-snapshot'))
|
||||
let reads = 0
|
||||
const intent = {
|
||||
get surfaceOp(): 'append' | undefined {
|
||||
reads += 1
|
||||
return reads === 1 ? 'append' : undefined
|
||||
},
|
||||
}
|
||||
|
||||
const event = session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
||||
intent as { surfaceOp: 'append' },
|
||||
)
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(event.surfaceOp).toBe('append')
|
||||
})
|
||||
|
||||
it('rejects non-JSON surface metadata before appending the event', () => {
|
||||
const session = new Session(SessionId('append-bad-metadata'))
|
||||
|
||||
@@ -578,44 +548,10 @@ 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')
|
||||
})
|
||||
|
||||
it('reads each supplied header field once before validation and publication', () => {
|
||||
const reads = { version: 0, id: 0, createdAt: 0, cwd: 0, parentSession: 0, seedLength: 0 }
|
||||
const header = {
|
||||
get version() { reads.version += 1; return reads.version === 1 ? SESSION_FORMAT_VERSION : 99 },
|
||||
get id() { reads.id += 1; return reads.id === 1 ? SessionId('header-once') : SessionId('drifted') },
|
||||
get createdAt() { reads.createdAt += 1; return reads.createdAt === 1 ? 123 : Number.NaN },
|
||||
get cwd() { reads.cwd += 1; return reads.cwd === 1 ? '/accepted' : 'relative' },
|
||||
get parentSession() { reads.parentSession += 1; return reads.parentSession === 1 ? SessionId('parent') : 1n },
|
||||
get seedLength() { reads.seedLength += 1; return reads.seedLength === 1 ? 0 : 1n },
|
||||
} as unknown as SessionHeader
|
||||
|
||||
const session = new Session(SessionId('header-once'), undefined, header)
|
||||
|
||||
expect(reads).toEqual({ version: 1, id: 1, createdAt: 1, cwd: 1, parentSession: 1, seedLength: 1 })
|
||||
expect(session.header).toEqual({
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: 'header-once',
|
||||
createdAt: 123,
|
||||
cwd: '/accepted',
|
||||
parentSession: 'parent',
|
||||
seedLength: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an exotic, non-JSON, or mismatched supplied header', () => {
|
||||
class ExoticHeader implements SessionHeader {
|
||||
readonly version = SESSION_FORMAT_VERSION
|
||||
@@ -624,7 +560,7 @@ describe('Session', () => {
|
||||
}
|
||||
|
||||
expect(() => new Session(SessionId('header-invalid'), undefined, new ExoticHeader()))
|
||||
.toThrow(/not a plain JSON record/)
|
||||
.toThrow(/not losslessly JSON-serializable/)
|
||||
expect(() => new Session(SessionId('header-invalid'), undefined, {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: SessionId('header-invalid'),
|
||||
@@ -740,79 +676,6 @@ describe('SessionStore', () => {
|
||||
expect(ctx.sessions.get(SessionId('racy'))).toBe(live)
|
||||
})
|
||||
|
||||
it('claims an id across Context.filter evaluation before committing the exact session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const id = SessionId('reentrant-enter')
|
||||
const nested = new Session(id)
|
||||
const outer = new Session(id)
|
||||
let nestedError = ''
|
||||
let attempted = false
|
||||
Object.defineProperty(outer, Context.filter, {
|
||||
configurable: true,
|
||||
get() {
|
||||
if (!attempted) {
|
||||
attempted = true
|
||||
try {
|
||||
ctx.sessions.enter(nested)
|
||||
} catch (error: unknown) {
|
||||
nestedError = String(error)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
const detach = ctx.sessions.enter(outer)
|
||||
expect(nestedError).toMatch(/already exists/)
|
||||
expect(ctx.sessions.get(id)).toBe(outer)
|
||||
detach()
|
||||
expect(ctx.sessions.get(id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('revalidates reservation ownership after carrier construction runs caller code', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const id = SessionId('released-during-enter')
|
||||
const reservation = ctx.sessions.reserve(id)
|
||||
const session = reservation.prepare()
|
||||
Object.defineProperty(session, Context.filter, {
|
||||
configurable: true,
|
||||
get() {
|
||||
reservation.release()
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
expect(() => ctx.sessions.enter(session, reservation)).toThrow(/does not own this prepared session/)
|
||||
expect(ctx.sessions.get(id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects when carrier construction attaches the same session to another store', async () => {
|
||||
const firstCtx = new Context()
|
||||
const secondCtx = new Context()
|
||||
await firstCtx.plugin(SessionStore)
|
||||
await secondCtx.plugin(SessionStore)
|
||||
const session = new Session(SessionId('cross-store-carrier'))
|
||||
let attempted = false
|
||||
let detachSecond = (): void => {}
|
||||
Object.defineProperty(session, Context.filter, {
|
||||
configurable: true,
|
||||
get() {
|
||||
if (!attempted) {
|
||||
attempted = true
|
||||
detachSecond = secondCtx.sessions.enter(session)
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
expect(() => firstCtx.sessions.enter(session)).toThrow(/already attached to a store/)
|
||||
expect(firstCtx.sessions.get(session.id)).toBeUndefined()
|
||||
expect(secondCtx.sessions.get(session.id)).toBe(session)
|
||||
detachSecond()
|
||||
})
|
||||
|
||||
it('prepare() + enter() + announce() register a session and emit session/created', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -834,7 +697,7 @@ describe('SessionStore', () => {
|
||||
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('captures the accepted id once and prevents simultaneous attachment to two stores', async () => {
|
||||
it('prevents simultaneous attachment of one session object to two stores', async () => {
|
||||
const firstCtx = new Context()
|
||||
const secondCtx = new Context()
|
||||
await firstCtx.plugin(SessionStore)
|
||||
@@ -842,7 +705,6 @@ describe('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)
|
||||
|
||||
@@ -852,69 +714,6 @@ describe('SessionStore', () => {
|
||||
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 () => {
|
||||
@@ -1009,54 +808,14 @@ describe('SessionStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reads session options and each metadata field once in prepare()', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const reads = { seed: 0, meta: 0, cwd: 0, parentSession: 0, createdAt: 0, seedLength: 0 }
|
||||
const meta = {
|
||||
get cwd() { reads.cwd += 1; return reads.cwd === 1 ? '/accepted' : 'relative' },
|
||||
get parentSession() { reads.parentSession += 1; return reads.parentSession === 1 ? SessionId('parent') : 1n },
|
||||
get createdAt() { reads.createdAt += 1; return reads.createdAt === 1 ? 123 : Number.NaN },
|
||||
get seedLength() { reads.seedLength += 1; return reads.seedLength === 1 ? 0 : 1n },
|
||||
}
|
||||
const options = {
|
||||
get seed() { reads.seed += 1; return reads.seed === 1 ? undefined : [] },
|
||||
get meta() { reads.meta += 1; return reads.meta === 1 ? meta : undefined },
|
||||
} as unknown as CreateSessionOptions
|
||||
|
||||
const session = ctx.sessions.prepare(SessionId('metadata-once'), options)
|
||||
|
||||
expect(reads).toEqual({ seed: 1, meta: 1, cwd: 1, parentSession: 1, createdAt: 1, seedLength: 1 })
|
||||
expect(session.header).toEqual({
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: 'metadata-once',
|
||||
createdAt: 123,
|
||||
cwd: '/accepted',
|
||||
parentSession: 'parent',
|
||||
seedLength: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects exotic metadata before cloning can erase its prototype', async () => {
|
||||
class ExoticMeta {
|
||||
readonly cwd = '/accepted'
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
expect(() => ctx.sessions.prepare(SessionId('exotic-meta'), { meta: new ExoticMeta() }))
|
||||
.toThrow(/session metadata is not a plain JSON record/)
|
||||
})
|
||||
|
||||
it('rejects non-JSON and invalid scalar session metadata', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const cases: Array<{ meta: unknown; error: RegExp }> = [
|
||||
{ meta: 1, error: /metadata is not a plain JSON record/ },
|
||||
{ meta: { parentSession: 1n }, error: /metadata is not losslessly JSON-serializable/ },
|
||||
{ meta: { cwd: 1 }, error: /session cwd must be a string/ },
|
||||
{ meta: { parentSession: 1 }, error: /parentSession must be a string/ },
|
||||
{ meta: { createdAt: '123' }, error: /createdAt must be a finite number/ },
|
||||
{ meta: { parentSession: 1n }, error: /header is not losslessly JSON-serializable/ },
|
||||
{ meta: { cwd: 1 }, error: /header cwd must be a string/ },
|
||||
{ meta: { parentSession: 1 }, error: /header parentSession must be a string/ },
|
||||
{ meta: { createdAt: '123' }, error: /header createdAt must be a finite number/ },
|
||||
{ meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
|
||||
{ meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
|
||||
{ meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
|
||||
@@ -1224,105 +983,6 @@ describe('SessionStore', () => {
|
||||
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)
|
||||
@@ -1342,34 +1002,10 @@ describe('SessionStore', () => {
|
||||
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',
|
||||
'session "reentrant-observer": session/event listener threw: Error: session append cannot reenter while another append is being 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)
|
||||
@@ -1425,12 +1061,9 @@ describe('SessionStore', () => {
|
||||
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', () => { throw new Error('sync disposed') })
|
||||
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'))
|
||||
@@ -1447,8 +1080,7 @@ describe('SessionStore', () => {
|
||||
|
||||
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 threw: Error: sync disposed',
|
||||
'session "contained-disposal": session/disposed listener rejected: Error: async disposed',
|
||||
])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user