diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 8f6b9604c5..0b0414d37f 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -8,16 +8,18 @@ This is the only package in the harness that contains concrete loop logic. Every ### Public API -Lifecycle (scoped and dual-owned): programmatic creation and resume snapshot caller-owned identity/configuration data, obtain registry/store-owned capabilities for both unpublished IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly; the trace-bound `AgentLoop` receiver still supplies the dependency origin, so a caller that injects only `agents` can create an agent whose scope reaches the loop's `sessions`/`llm`/`tools`/`systemPrompt` surface. The caller owns cancellation and the returned handle, while AgentLoop remains a structural second owner because the live driver depends on that service surface: unloading the provider aborts pending load/setup, tears down live programmatic agents, and awaits the same quiescence and ID-release boundary before its dependencies disappear. +Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible. -The complete create transaction is factory-tracked before ID reservation/session validation, and both a factory placeholder and lifecycle-long caller sentinel exist before scope minting can reenter plugin lifecycle notifications. The caller sentinel adopts the exact reservation effects and always follows the memoized lifecycle boundary, including handle-first teardown followed by caller unload. Resume adds a load sentinel before persistence I/O; it waits for load settlement until `startOwned` synchronously returns a lifecycle/rollback disposer, then follows that disposer without a handoff gap. The ID capabilities reject competing `register`/`enter`/`prepare`/`create` calls and remain held through scope quiescence. A create hands one-read raw seed and metadata references synchronously to the session boundary, which rejects exotic shells and materializes accepted values in a single recursive pass; pre-cloning either value could incorrectly sanitize prototypes. Resume captures each loaded metadata field once. After setup resolves, the factory checks caller and provider liveness after constructing both registry entries but before the first announcement, after `session/created`, after `agent/created`, and again after `agent/session-start` before starting the driver, so synchronous getter- or listener-triggered teardown wins. A publication-wide barrier flips lifecycle liveness immediately but keeps both entries and `agent.ctx` intact until the current synchronous notification phase unwinds; only then does rollback revoke them. Registry/store entries claim IDs across caller-code commit windows, detach exact objects only, and reuse stable carriers for paired edges. Load/setup rejection or owner unload before announcement emits no creation edge; if teardown begins inside a creation or session-start listener, the already-started notifications are paired during rollback and no live or drivable publication survives. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope → release reservations. After quiescence, the caller sentinel and any resume-load sentinel disarm and remove their owner-fiber effects so a long-lived caller does not retain the completed agent and scope. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`; the registry's paired disposal edge applies the same failure containment through its captured carrier. Per-step assembly goes through `assembleContextFor(agent)`, and the turn-end durability checkpoint goes through `ctx.sessions.flush(session)`. +The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear. + +IDs are caller-chosen and assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same agent or session id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; IDs become reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`. - `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create, used directly by programs and by `cordis.yml`-configured agents. It creates a fresh per-run session id `${id}-session-` with optional metadata; the uuid avoids colliding with a prior durable log. Each call is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber. `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): -- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup? }): Promise` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and detaches each raw value in one pass. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. -- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`. +- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. +- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup?, signal? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`. The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code. @@ -44,7 +46,7 @@ Agents listed in config are auto-created at startup. `cwd` applies only to fresh - `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy. -`Inbox`, `runLoop`, and the instance-bound enable/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary. +`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary. ### Loop lifecycle (`loop.ts`) @@ -91,7 +93,7 @@ forever: idle unless more queued ``` -Error containment: a throwing plugin ends the **turn**, never the loop. A malformed or throwing `agent/turn-stop` policy likewise fails the turn closed. A successful terminal stop stays authoritative through `turn/end` and `session/flush`, preventing their listeners from resurrecting steering through the late fallback. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop. +Error containment: a throwing plugin ends the **turn**, never the loop. A throwing `agent/turn-stop` policy likewise fails the turn closed. A successful terminal stop stays authoritative through `turn/end` and `session/flush`, preventing their listeners from resurrecting steering through the late fallback. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop. Cancellation: `agent.cancel()` is the single public stop primitive — it clears the queued + steering FIFOs, aborts the in-flight step, and drives a turn-scoped marker the driver checks at every point a turn could start or continue (right after the idle wait, after the `running` flip, before each step, and at the continuation gate) so a turn about to start is dropped. A cancelled turn ends `aborted`; a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. The marker is reset once per loop iteration, so a cancel governs exactly one turn and never leaks onto a later prompt. (The loop still aborts its own per-step `AbortController` directly on disposal and from `cancel()`; that controller is loop-internal, not a public verb.) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index a449e6c907..ff66f942be 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -16,9 +16,6 @@ import { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session' import { Inbox, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' -/** Agents whose rollback-covered publication enabled driving. */ -const driveEnabledAgents = new WeakSet() - /** Sessions already claimed by a concrete driver construction. */ const claimedDriverSessions = new WeakSet() @@ -28,12 +25,18 @@ const startDriver = Symbol('dsh.agent-loop.start-driver') /** Module-private quiescent stop, valid both before and after driver start. */ const stopDriver = Symbol('dsh.agent-loop.stop-driver') +/** Module-private context binding for the mutually referential agent scope. */ +const bindContext = Symbol('dsh.agent-loop.bind-context') + +/** Module-private publication marker. */ +const publishAgent = Symbol('dsh.agent-loop.publish-agent') + /** Factory-owned controls that can operate only on the agent created with them. */ export interface PreparedReactLoopAgent { /** The unpublished concrete agent. */ agent: ReactLoopAgent - /** Open its driving verbs at the rollback-covered publication boundary. */ - enableDrive(): void + /** Mark the agent public so teardown emits its status lifecycle. */ + markPublished(): void /** Stop the prepared instance even when publication has not started its loop. */ dispose(): Promise | void /** @@ -48,7 +51,7 @@ export interface PreparedReactLoopAgent { * Construct one concrete agent together with unforgeable, instance-bound * lifecycle controls. The package surface deliberately exposes neither source * subpaths nor this helper: setup code may identify the concrete class, but it - * cannot enable or start the factory's unpublished instance. + * cannot publish or start the factory's unpublished instance. * @param ctx - the agent-loop service context used for driving and events. * @param id - the concrete agent identity. * @param options - loop options for the agent. @@ -62,14 +65,11 @@ export function prepareReactLoopAgent( throw new Error(`session "${session.id}" already has a concrete agent driver`) } const agent = new ReactLoopAgent(ctx, id, options, session) - // Construction snapshots caller options and can throw. Claim only the fully - // initialized driver so the same prepared session remains retryable after a - // rejected caller value. claimedDriverSessions.add(session) const dispose = () => agent[stopDriver]() return { agent, - enableDrive: () => { driveEnabledAgents.add(agent) }, + markPublished: () => { agent[publishAgent]() }, dispose, startDriver: () => { agent[startDriver]() @@ -82,20 +82,12 @@ export function prepareReactLoopAgent( * Install the concrete agent's scope context exactly once. Construction and * scope minting are mutually referential (the scope key is the agent), so the * factory performs this one post-construction binding before setup receives - * the unpublished agent. The runtime slot is non-writable/non-configurable; - * TypeScript `readonly` alone would still let JavaScript redirect later - * registrations to another context. + * the unpublished agent. The module-private binding rejects a second bind. * @param agent - the unpublished concrete agent to bind. * @param ctx - its fully extended agent scope context. */ export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context): void { - if (Object.hasOwn(agent, 'ctx')) throw new Error(`agent "${agent.id}" context is already bound`) - Object.defineProperty(agent, 'ctx', { - value: ctx, - enumerable: true, - writable: false, - configurable: false, - }) + agent[bindContext](ctx) } /** @@ -106,7 +98,7 @@ export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context): * the agent/* event taxonomy — plugins never need this class. */ export class ReactLoopAgent implements Agent { - /** Queued + steering FIFOs; native-private so setup cannot bypass driving verbs. */ + /** Queued + steering FIFOs; native-private so callers cannot bypass the public driving verbs. */ readonly #inbox = new Inbox() /** @@ -117,12 +109,20 @@ export class ReactLoopAgent implements Agent { * context are mutually referential (the scope is keyed BY this agent), so * neither can exist strictly before the other. */ - declare readonly ctx: Context + private boundContext: Context | undefined + + /** The agent's scoped composition context, bound once by its factory. */ + get ctx(): Context { + if (this.boundContext === undefined) throw new Error(`agent "${this.id}" context is not bound`) + return this.boundContext + } private _status: AgentStatus = 'idle' private currentAbort: AbortController | undefined /** Whether runLoop has been installed into {@link done}. */ private driverStarted = false + /** Whether registry publication began and status disposal is externally visible. */ + private published = false /** * Turn-scoped cancel marker, set by {@link cancel} and read/cleared by the * driver loop (via the LoopHandle) at every point a turn could start or @@ -167,16 +167,6 @@ export class ReactLoopAgent implements Agent { public readonly options: AgentOptions, public readonly session: Session, ) { - const acceptedOptions = deepFreeze(structuredClone(options)) - // Pin the public ownership/identity bindings in the runtime object. A - // JavaScript caller can otherwise replace TS-readonly parameter properties - // after publication and split the registry, driver, session, and model - // configuration into different worlds. - Object.defineProperties(this, { - id: { value: id, enumerable: true, writable: false, configurable: false }, - options: { value: acceptedOptions, enumerable: true, writable: false, configurable: false }, - session: { value: session, enumerable: true, writable: false, configurable: false }, - }) const { promise, resolve } = Promise.withResolvers() this.disposed = promise this.resolveDisposed = resolve @@ -233,36 +223,24 @@ export class ReactLoopAgent implements Agent { if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) } - /** Reject every driving verb while creation setup still owns the agent. */ - private assertDriveEnabled(action: string): void { - if (driveEnabledAgents.has(this)) return - throw new Error(`agent "${this.id}" cannot ${action} before creation setup completes`) - } - send(content: ContentBlock[], options?: SendOptions): void { - this.assertDriveEnabled('send') this.assertNotDisposed() const accepted = this.acceptInboxMessage(content, options) - // Materialization invokes caller getters, which may reenter handle disposal. - this.assertNotDisposed() this.#inbox.enqueue(accepted) - const info = deepFreeze({ source: accepted.source, steering: false }) + const info = { source: accepted.source, steering: false } as const agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info) } steer(content: ContentBlock[], options?: SendOptions): void { - this.assertDriveEnabled('steer') this.assertNotDisposed() if (this._status !== 'running') { this.send(content, options); return } const accepted = this.acceptInboxMessage(content, options) - this.assertNotDisposed() this.#inbox.steer(accepted) - const info = deepFreeze({ source: accepted.source, steering: true }) + const info = { source: accepted.source, steering: true } as const agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info) } inject(content: ContentBlock[], options?: SendOptions): void { - this.assertDriveEnabled('inject') this.assertNotDisposed() const source = this.resolveSource(options) if (isTurnOpen(this.session)) { @@ -323,7 +301,6 @@ export class ReactLoopAgent implements Agent { } cancel(reason?: string): void { - this.assertDriveEnabled('cancel') // Arm-gate: only mark a cancellation when there is actually work to cancel — // a running turn, an in-flight step, or queued/steering work. An idle cancel // with nothing pending is a true no-op; arming the marker then would wrongly @@ -382,6 +359,17 @@ export class ReactLoopAgent implements Agent { }) } + /** Bind the mutually referential scope context once. */ + private [bindContext](ctx: Context): void { + if (this.boundContext !== undefined) throw new Error(`agent "${this.id}" context is already bound`) + this.boundContext = ctx + } + + /** Mark that public lifecycle publication began. */ + private [publishAgent](): void { + this.published = true + } + /** * Start the driver loop. The prepared controller already owns its stable * disposer, so teardown can mark the agent disposed even in the narrow @@ -424,15 +412,15 @@ export class ReactLoopAgent implements Agent { this.settleIdleWaiters() this.currentAbort?.abort('disposed') // An unpublished rollback has no public status lifecycle to announce. - // Once driving is enabled, disposed is part of the agent/status contract. - if (driveEnabledAgents.has(this)) { + // Once publication begins, disposed is part of the agent/status contract. + if (this.published) { agentEvents(this.loopCtx, this).emit('agent/status', 'disposed') } } // Before runLoop starts there is normally nothing asynchronous to drain; // keep publication rollback synchronous so create() cannot throw while its // session/agent entries are still briefly live. A session-start listener - // may have used the newly enabled inject() surface, however, so preserve + // may have called inject(), however, so preserve // its durability checkpoint as a real quiescence boundary. if (!this.driverStarted && this.pendingIdleFlushes.size === 0) return return this.drainDriver() @@ -455,11 +443,7 @@ export class ReactLoopAgent implements Agent { } } -/** Render an arbitrary thrown value without allowing coercion to throw again. */ +/** Render an ordinary thrown value for the error event and log. */ function renderThrown(value: unknown): string { - try { - return value instanceof Error ? value.message : String(value) - } catch { - return '' - } + return value instanceof Error ? value.message : String(value) } diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 8722751962..3f717378d2 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -1,109 +1,328 @@ /** - * THE concrete agent plugin: creates ReactLoopAgents, runs their loops, and - * registers them in ctx.agents. Deliberately thin — every behavior beyond - * "call the model, run the tools, repeat" belongs to plugins on the event - * taxonomy. + * Concrete agent-loop plugin: creates scoped ReactLoopAgents, publishes them + * through the agent/session registries, and owns their ordered teardown. * * @module @deepseek-ai/dsh-agent-loop */ -import { Context, CordisError, FiberState, Service, symbols } from 'cordis' +import { Context, FiberState, Service } from 'cordis' import { randomUUID } from 'node:crypto' import z from 'schemastery' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import { agentEvents } from '@deepseek-ai/dsh-agent' -import type { AgentFactory, AgentHandle, AgentId, AgentOptions, AgentRegistrationReservation, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent' +import type { + AgentFactory, + AgentHandle, + AgentId, + AgentOptions, + CreateAgentOptions, + ResumeAgentOptions, + SessionStartSource, +} from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-llm' -import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session' -import type { Session, SessionRegistrationReservation } from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionHeader } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { bindReactLoopAgentContext, prepareReactLoopAgent, ReactLoopAgent } from './agent.ts' +import { + bindReactLoopAgentContext, + prepareReactLoopAgent, + ReactLoopAgent, +} from './agent.ts' +import type { PreparedReactLoopAgent } from './agent.ts' export { ReactLoopAgent } from './agent.ts' -/** Both unpublished identity capabilities held by one factory transaction. */ -interface RegistrationReservations { - agent: AgentRegistrationReservation - session: SessionRegistrationReservation - release(): void -} - -/** A synchronously established ownership handoff plus its async publication result. */ -interface OwnedAgentStart { - result: Promise - dispose: () => Promise -} - -/** Internal carrier for a preparation error whose rollback still has to quiesce. */ -class LifecyclePreparationFailure extends Error { - constructor( - readonly reason: unknown, - readonly dispose: () => Promise, - ) { - super('agent lifecycle preparation failed', { cause: reason }) - this.name = 'LifecyclePreparationFailure' - } -} - -/** Stable construction-time state shared by every traceable AgentLoop receiver. */ -interface FactoryOwnership { - isActive(): boolean - track(dispose: () => Promise): () => void - dispose(): Promise -} - -/** Fiber states in which a concrete factory cannot safely serve dependencies. */ -const INACTIVE_FACTORY_STATES: ReadonlySet = new Set([ +/** Fiber states that cannot own or serve a new lifecycle. */ +const INACTIVE_STATES: ReadonlySet = new Set([ FiberState.UNLOADING, FiberState.DISPOSED, FiberState.FAILED, ]) -/** Build a tamper-resistant controller around one factory's private ledger. */ -function createFactoryOwnership(fiber: Context['fiber']): FactoryOwnership { - let accepting = true - const transactions = new Set<() => Promise>() - const isActive = (): boolean => accepting && !INACTIVE_FACTORY_STATES.has(fiber.state) - return Object.freeze({ - isActive, - track(dispose: () => Promise): () => void { - /* v8 ignore next -- every call site checks the same controller immediately - * before this synchronous, non-reentrant insertion; retain the guard as an invariant */ - if (!isActive()) throw new Error('agent loop is not active') - transactions.add(dispose) - return () => { transactions.delete(dispose) } - }, - async dispose(): Promise { - accepting = false - const disposers = [...transactions] - transactions.clear() - const results = await Promise.allSettled(disposers.map(dispose => Promise.resolve().then(dispose))) - /* v8 ignore next -- tracked lifecycle/load boundaries are deliberately - * infallible; keep reasons if that lower-level contract ever breaks */ - const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason as unknown] : []) - /* v8 ignore next -- every tracked boundary is deliberately infallible; - * preserve an exact unexpected single failure as a defensive backstop */ - if (errors.length === 1) throw errors[0] - /* v8 ignore next -- multiple failures require multiple contract-breaking - * lifecycle disposers, but teardown must still retain every cause */ - if (errors.length > 1) throw new AggregateError(errors, 'agent loop transaction disposal failed') - }, - }) +/** Factory-level ownership of every preparing or live transaction. */ +class FactoryOwnership { + private accepting = true + private transactions = new Set() + + constructor(private readonly fiber: Context['fiber']) {} + + isActive(): boolean { + return this.accepting && !INACTIVE_STATES.has(this.fiber.state) + } + + track(transaction: AgentCreationTransaction): () => void { + if (!this.isActive()) throw new Error('agent loop is not active') + this.transactions.add(transaction) + return () => { this.transactions.delete(transaction) } + } + + async dispose(): Promise { + this.accepting = false + const reason = new Error('agent loop is not active') + const results = await Promise.allSettled( + [...this.transactions].map(transaction => transaction.disposeForFactory(reason)), + ) + const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason as unknown] : []) + if (errors.length === 1) throw errors[0] + if (errors.length > 1) throw new AggregateError(errors, 'agent loop transaction disposal failed') + } } -/** Private ownership controllers keyed by the concrete, unproxied service. */ -const factoryOwnerships = new WeakMap() +/** Build the public cancellation error while preserving a caller-supplied cause. */ +function signalAbortError(id: AgentId, signal: AbortSignal): Error { + if (signal.reason instanceof Error) return signal.reason + return new Error(`agent "${id}" creation aborted`, { cause: signal.reason }) +} -/** Recover the stable controller when a Cordis trace proxy is the receiver. */ -function factoryOwnershipFor(loop: AgentLoop): FactoryOwnership { - const original = (loop as AgentLoop & { [symbols.original]?: AgentLoop })[symbols.original] ?? loop - // Installed immediately after Service construction, before AgentLoop starts - // any effect or config-driven transaction. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return factoryOwnerships.get(original)! +/** + * One create/resume transaction from caller ownership through unpublished + * setup, rollback-covered publication, and final quiescent teardown. + * + * The class deliberately owns the state machine in one place. Registries only + * arbitrate identity at their final `enter()` calls; before that point every + * resource is private to this transaction. + */ +class AgentCreationTransaction { + private active = true + private failure: Error | undefined + private readonly deactivation = Promise.withResolvers() + private readonly publication = Promise.withResolvers() + private readonly torndown = Promise.withResolvers() + private readonly wrapperCompletion = Promise.withResolvers() + private preparing: Promise | undefined + private driver: PreparedReactLoopAgent | undefined + private scope: Scope | undefined + private session: Session | undefined + private lifecycleDispose: (() => Promise | void) | undefined + private detachSession: (() => void) | undefined + private detachAgent: (() => void) | undefined + private publishing = false + private cleanupTask: Promise | undefined + private finished = false + private wrapperFinished = false + private ownerFollowing = true + private readonly ownerDispose: () => Promise | void + private readonly untrackFactory: () => void + private readonly abortListener: (() => void) | undefined + readonly ownerAgent: Context['agent'] + readonly ownerFiber: Context['fiber'] + + constructor( + private readonly loopCtx: Context, + private readonly ownerCtx: Context, + private readonly ownership: FactoryOwnership, + readonly id: AgentId, + signal?: AbortSignal, + ) { + ownerCtx.fiber.assertActive() + this.ownerAgent = ownerCtx.agent + this.ownerFiber = ownerCtx.fiber + this.untrackFactory = ownership.track(this) + try { + this.ownerDispose = ownerCtx.effect(() => () => { + if (!this.ownerFollowing) return + return this.dispose(new Error(`agent "${id}" setup aborted: owner disposed during setup`)) + }, `agentLoop.owner(${id})`) + } catch (error: unknown) { + this.untrackFactory() + throw error + } + if (signal === undefined) { + this.abortListener = undefined + } else { + this.abortListener = () => { + void this.dispose(signalAbortError(id, signal)).catch((error: unknown) => { + this.loopCtx.logger.error(error) + }) + } + signal.addEventListener('abort', this.abortListener, { once: true }) + if (signal.aborted) this.deactivate(signalAbortError(id, signal)) + } + this.signal = signal + } + + private readonly signal: AbortSignal | undefined + + /** Whether caller, provider, and optional parent-agent ownership remain live. */ + isActive(): boolean { + return this.active + && this.ownership.isActive() + && this.ownerFiber.uid !== null + && !INACTIVE_STATES.has(this.ownerFiber.state) + && this.ownerAgent?.status !== 'disposed' + } + + /** Fail synchronously at every real lifecycle boundary after deactivation. */ + assertActive(): void { + if (this.isActive()) return + if (!this.ownership.isActive()) throw new Error('agent loop is not active') + throw this.failure ?? new Error(`agent "${this.id}" setup aborted: owner disposed during setup`) + } + + /** Race an external async operation against structural/signal deactivation. */ + async waitFor(operation: PromiseLike | T): Promise { + this.assertActive() + return await Promise.race([ + Promise.resolve(operation), + this.deactivation.promise.then(() => { + throw this.failure ?? new Error(`agent "${this.id}" creation deactivated`) + }), + ]) + } + + /** Construct the driver and scope, then install their complete ordered lifecycle. */ + prepare(options: AgentOptions, session: Session): ReactLoopAgent { + this.assertActive() + const gate = Promise.withResolvers() + this.preparing = gate.promise + try { + this.session = session + const driver = prepareReactLoopAgent(this.loopCtx, this.id, options, session) + this.driver = driver + const agent = driver.agent + const scope = createScope(this.loopCtx, agent) + this.scope = scope + bindReactLoopAgentContext(agent, scope.ctx.extend({ agent })) + this.installLifecycle(scope, driver) + this.assertActive() + return agent + } catch (error: unknown) { + if (!this.isActive() && error instanceof Error && /inactive context/.test(error.message)) { + throw this.failure ?? this.disposalReason() + } + throw error + } finally { + gate.resolve() + this.preparing = undefined + } + } + + /** Register the exact scope disposer inside the ordered transaction effect. */ + private installLifecycle(scope: Scope, driver: PreparedReactLoopAgent): void { + this.lifecycleDispose = this.ownerCtx.effect(function* (this: AgentCreationTransaction) { + // First yielded, disposed last. + yield () => { this.finish() } + yield scope.rawDispose + yield () => { + this.detachSession?.() + this.detachSession = undefined + } + yield () => { + this.detachAgent?.() + this.detachAgent = undefined + } + // Last yielded, disposed first. + yield () => { + this.deactivate(this.disposalReason()) + if (this.publishing) { + return this.publication.promise.then(() => driver.dispose()) + } + return driver.dispose() + } + }.bind(this), `agentLoop.lifecycle(${this.id})`) + } + + /** Publish the exact prepared objects and start the driver. */ + publish(source: SessionStartSource): AgentHandle { + this.assertActive() + const driver = this.driver + if (driver === undefined) throw new Error(`agent "${this.id}" is not prepared`) + const agent = driver.agent + const session = this.session + if (session === undefined) throw new Error(`agent "${this.id}" has no prepared session`) + this.publishing = true + try { + this.detachSession = agent.ctx.sessions.enter(session) + this.detachAgent = this.loopCtx.agents.enter(agent) + + agent.ctx.sessions.announce(session) + this.assertActive() + this.loopCtx.agents.announce(agent) + this.assertActive() + + driver.markPublished() + agentEvents(this.loopCtx, agent).emit('agent/session-start', source) + this.assertActive() + driver.startDriver() + return { agent, dispose: () => this.dispose() } + } finally { + this.publishing = false + this.publication.resolve() + } + } + + /** Mark the transaction inactive exactly once and wake load/setup races. */ + private deactivate(reason: Error): void { + if (!this.active) return + this.active = false + this.failure = reason + this.deactivation.resolve() + } + + /** Choose the structural cause when an owner/factory effect starts teardown first. */ + private disposalReason(): Error { + if (this.failure !== undefined) return this.failure + if (!this.ownership.isActive()) return new Error('agent loop is not active') + if (this.ownerFiber.uid === null || INACTIVE_STATES.has(this.ownerFiber.state) || this.ownerAgent?.status === 'disposed') { + return new Error(`agent "${this.id}" setup aborted: owner disposed during setup`) + } + return new Error(`agent "${this.id}" lifecycle disposed`) + } + + /** Complete ownership bookkeeping after every resource reached quiescence. */ + private finish(): void { + if (this.finished) return + this.finished = true + this.untrackFactory() + this.ownerFollowing = false + void this.ownerDispose() + this.torndown.resolve() + } + + /** + * Deactivate and quiesce this transaction. The promise is memoized because + * Cordis effect disposers are single-shot while handles promise shared + * quiescence to every racing owner. + */ + dispose(reason = new Error(`agent "${this.id}" lifecycle disposed`)): Promise { + this.deactivate(reason) + return (this.cleanupTask ??= (async () => { + if (this.preparing !== undefined) await this.preparing + if (this.lifecycleDispose !== undefined) { + await this.lifecycleDispose() + await this.torndown.promise + return + } + try { + await this.driver?.dispose() + } finally { + try { + await this.scope?.dispose() + } finally { + this.finish() + } + } + })()) + } + + /** Mark the public create/resume continuation settled and detach its creation-only signal. */ + finishWrapper(): void { + if (this.wrapperFinished) return + this.wrapperFinished = true + if (this.signal !== undefined && this.abortListener !== undefined) { + this.signal.removeEventListener('abort', this.abortListener) + } + this.wrapperCompletion.resolve() + } + + /** Factory shutdown joins both resource teardown and the public wrapper's deactivation continuation. */ + async disposeForFactory(reason: Error): Promise { + await this.dispose(reason) + await this.wrapperCompletion.promise + } } declare module 'cordis' { @@ -112,52 +331,24 @@ declare module 'cordis' { } } -/** - * Plugin config: the agents to create — or resume, via `resumeSessionId` — - * declaratively at startup, so a cordis.yml deployment needs no code. - */ +/** Plugin configuration for declarative startup agents. */ export interface Config { - /** Agents created from configuration at startup. */ + /** Agents created or resumed at plugin startup. */ agents: (AgentOptions & { - /** Agent id to register under; also seeds the fresh per-run session id (`${id}-session-`). */ + /** Registry identity for the live agent. */ id: AgentId - /** Optional workspace cwd for the config-created fresh session. */ + /** Optional workspace for a fresh session. */ cwd?: string - /** - * If set, the config agent RESUMES this persisted session id instead of - * starting a fresh `${id}-session-`. Sourced from an env var in - * cordis.yml (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`), so a - * demo can continue a prior conversation without code changes. Requires a - * `dsh-session-persistence` backend; the resume is deferred until that - * service is available (via `ctx.inject`) and the loaded session's events - * seed the live session so history continues. - * - * The schema accepts a plain string at runtime (cordis.yml values are - * untyped); the brand is compile-time only — the config format is the - * boundary where an id enters, so the TYPE declares the brand here. - */ + /** Persisted session to resume instead of creating a fresh session. */ resumeSessionId?: SessionId })[] } -/** - * The agent-loop plugin (`ctx.agentLoop`): creates {@link ReactLoopAgent}s, runs - * their loops, and registers them in `ctx.agents`. Also implements the - * {@link AgentFactory} seam, so plugins create/resume agents through - * `ctx.agents` (the interface) without depending on this concrete package. - * - * The loop itself is deliberately thin — every behavior beyond "call the - * model, run the tools, repeat" belongs to plugins listening on the event - * taxonomy declared in @deepseek-ai/dsh-agent. - */ +/** Concrete ReactLoopAgent factory and driver service. */ export class AgentLoop extends Service implements AgentFactory { static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'] - // The schema validates plain strings (cordis.yml config values are untyped at - // runtime); the {@link Config} TYPE declares the branded `id`/`resumeSessionId` - // because the config format is the boundary where an id enters. The brand is a - // zero-cost compile-time cast, so the runtime schema stays string-based and we - // assert the branded view once here — the single schema boundary. + /** Runtime schema for declarative agents. */ static Config = z.object({ agents: z.array(z.object({ id: z.string().required(), @@ -167,782 +358,143 @@ export class AgentLoop extends Service implements AgentFactory { })).default([]), }) as unknown as z + private readonly ownership: FactoryOwnership + /** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */ + private readonly runtime: { ctx: Context } + constructor(ctx: Context, public config: Config) { super(ctx, 'agentLoop') - const factoryOwnership = createFactoryOwnership(ctx.fiber) - factoryOwnerships.set(this, factoryOwnership) - // Programmatic agents are caller-owned, but this implementation is their - // dependency provider too. Retain a second ownership edge so unloading the - // loop aborts unpublished work and drains every live lifecycle before its - // service surface disappears. - ctx.effect(() => () => factoryOwnership.dispose(), 'agentLoop.factoryTransactions()') - // Provide the agent-creation factory to the registry (effect-scoped: the - // slot is cleared on dispose). - ctx.effect(() => this.ctx.agents.setFactory(this), 'agentLoop.setFactory()') - // The prompt variables the shipped loop provides, registered once. The - // sections themselves (`harness:identity`, `deployment:persona`) belong to - // dsh-system-prompt — they must survive a swapped loop plugin — but - // `{{model}}`/`{{cwd}}` are runtime facts of the agents THIS loop drives: - // it assembles with `{ agent }` each step (loop.ts), and the variables - // project the agent's configured model and its session workspace from that - // context. A provider returns undefined when the fact is absent - // (renderPrompt then rejects a persona that claims it — fail loud). + this.ownership = new FactoryOwnership(ctx.fiber) + this.runtime = { ctx } + ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()') + ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()') ctx.systemPrompt.variable('model', context => context.agent?.options.model) ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd) + for (const { id, cwd, resumeSessionId, ...options } of config.agents) { - if (resumeSessionId !== undefined && resumeSessionId !== '') { - // Resume a prior session instead of starting fresh. resume() needs - // `ctx.sessionPersistence`, which may load AFTER this plugin (cordis.yml - // lists the backend later). `ctx.inject(['sessionPersistence'], cb)` - // runs `cb` with a child ctx once the service exists; the child reads - // the persistence and hands it to resumeWith (which uses this.ctx — the - // parent — for sessions/registry, all in AgentLoop's static inject). A - // failed resume is contained + logged: startup must not crash. - ctx.effect(() => { - const fiber = this.ctx.inject(['sessionPersistence'], (childCtx: Context) => { - void this.resumeWith(ctx, childCtx.sessionPersistence, { - agentId: id, - resumeSessionId, - agentOptions: options, - }) - .catch((error: unknown) => { - this.ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`) - }) - }) - // Return the EXACT child-fiber disposer. Cordis moves a returned - // effect into this labeled owner's teardown tree by function - // identity; a wrapper would leave the child as a concurrent sibling - // and could discard its async quiescence promise. - return fiber.dispose - }, `agentLoop.resume(${id})`) - } else { + if (resumeSessionId === undefined || resumeSessionId === '') { this.create(id, options, cwd === undefined ? {} : { cwd }) + continue } + ctx.effect(() => { + const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => { + void this.resumeWith(ctx, childCtx.sessionPersistence, { + agentId: id, + resumeSessionId, + agentOptions: options, + }).catch((error: unknown) => { + ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`) + }) + }) + return fiber.dispose + }, `agentLoop.resume(${id})`) } } - /** Whether this concrete factory may begin or publish more work. */ - private factoryIsActive(): boolean { - return factoryOwnershipFor(this).isActive() - } - - /** Reject a call that raced the concrete loop's unload boundary. */ - private assertFactoryActive(): void { - if (this.factoryIsActive()) return - throw new Error('agent loop is not active') - } - - /** Add one memoized quiescence boundary to the factory's ownership set. */ - private trackFactoryTransaction(dispose: () => Promise): () => void { - return factoryOwnershipFor(this).track(dispose) - } - /** - * Config-driven create: an agent on a FRESH, non-colliding session id per run - * (`${id}-session-`). Used for `cordis.yml`-configured agents and as - * the shared core for the programmatic factory {@link createAgent}. - * - * Why a per-run id, not a fixed `${id}-session`: once a durable persistence - * backend is loaded, a fixed id collides on the second run — the backend - * refuses to re-create an id whose log already exists on disk (the SessionId - * is the identity). A fresh id means each run is a new session. - * - * TODO(demo): each run starting a brand-new session is fine for demos but is - * NOT real conversation continuity. A production config-driven agent needs a - * deliberate resume-or-create policy (resume the prior session if one exists, - * else start fresh) or an explicit caller-chosen session id — revisit when the - * UI/ACP path owns session selection. - * @param id - the agent id; also seeds the generated session id. - * @param options - loop options (model, limits, …); defaults applied per option. - * @param meta - optional session metadata for the fresh session. - * @returns the running agent, owned by the calling fiber (no handle). + * Create an agent on a fresh per-run session, owned by the accessing fiber. + * Constructor-driven config calls use the loop fiber itself. + * @param id - agent registry id. + * @param options - concrete loop options. + * @param meta - optional fresh-session workspace metadata. + * @returns the published running agent. */ create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent { - const sessionId = SessionId(`${id}-session-${randomUUID()}`) - const reservations = this.reserve(id, sessionId) - // Config/programmatic path: prepare the session and let start() fold its - // lifecycle into the agent's composite effect (so a fiber unload tears the - // session + agent down as one ordered chain, capturing the loop's closing - // flush). The whole effect is owned by THIS fiber; no AgentHandle is needed. - let session: Session + const loopCtx = this.runtime.ctx + const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id) try { - session = reservations.session.prepare({ meta }) + const sessionId = SessionId(`${id}-session-${randomUUID()}`) + const session = loopCtx.sessions.prepare(sessionId, { meta }) + const agent = transaction.prepare(options, session) + transaction.publish('startup') + return agent } catch (error: unknown) { - reservations.release() + void transaction.dispose(error instanceof Error ? error : new Error(String(error))) throw error + } finally { + transaction.finishWrapper() } - // start() accepts ownership of both reservation capabilities even when - // synchronous preparation fails; its rollback releases them at quiescence. - const { agent } = this.start(id, options, session, 'startup', reservations) - return agent } /** - * Programmatic factory create ({@link AgentFactory}): an agent on a - * caller-supplied `sessionId` (NOT `${id}-session`), with optional session - * metadata (validated `cwd`, lineage) and an optional `seed` event prefix. The - * ACP bridge uses this so the client-generated session id becomes the - * live/persisted session id; the in-process FORK subagent backend passes a - * `seed` (a balanced completed-turn prefix of the parent's log) so the child - * starts with the parent's context. Returns an {@link AgentHandle} the owner - * disposes to tear down exactly this agent. - * @param ownerCtx - the caller context that owns setup and the live lifecycle. - * @param options - agent id, caller-supplied session id, optional seed/meta, - * and agent options. - * @returns the handle whose dispose tears down exactly this agent. + * Create an owned agent on a caller-supplied session id. + * @param ownerCtx - caller context that structurally owns the transaction. + * @param options - identities, session seed/metadata, loop options, setup, and cancellation. + * @returns the published handle. */ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise { - this.assertFactoryActive() - // Snapshot every caller-owned field before the first async setup boundary. - // The callback itself is an identity capability. Agent options detach here; - // seed and metadata stay raw only until sessions.prepare() synchronously - // reads, validates, and detaches them, so structuredClone cannot erase an - // exotic prototype before the session boundary sees it. - const agentId = options.agentId - const sessionId = options.sessionId - const setup = options.setup - const agentOptions = structuredClone(options.agentOptions ?? {}) - const seed = options.seed - const meta = options.meta - // Snapshot accessors can reenter plugin teardown; do not reserve identities - // after the dependency provider has begun unloading. - this.assertFactoryActive() - const { promise: transactionSettled, resolve: markTransactionSettled } = Promise.withResolvers() - const disposeCreateForFactory = (): Promise => transactionSettled - const untrackFactoryCreate = this.trackFactoryTransaction(disposeCreateForFactory) + const transaction = new AgentCreationTransaction( + this.runtime.ctx, + ownerCtx, + this.ownership, + options.agentId, + options.signal, + ) try { - const reservations = this.reserve(agentId, sessionId) - let lifecycleStarted = false - try { - const session = reservations.session.prepare({ - ...seed !== undefined ? { seed } : {}, - ...meta !== undefined ? { meta } : {}, - }) - // A seeded (forked) create is still a fresh start, NOT a resume. - lifecycleStarted = true - return await this.startOwned(ownerCtx, agentId, agentOptions, session, 'startup', reservations, setup).result - } finally { - // Once startOwned is invoked, even a preparation failure carries its - // own quiescent rollback boundary. Only failures before that handoff - // release directly here. - if (!lifecycleStarted) reservations.release() - } + const session = this.runtime.ctx.sessions.prepare(options.sessionId, { + ...options.seed === undefined ? {} : { seed: options.seed }, + ...options.meta === undefined ? {} : { meta: options.meta }, + }) + const agent = transaction.prepare(options.agentOptions ?? {}, session) + await transaction.waitFor(options.setup?.(agent.ctx)) + transaction.assertActive() + return transaction.publish('startup') + } catch (error: unknown) { + await transaction.dispose(error instanceof Error ? error : new Error(String(error))) + throw error } finally { - markTransactionSettled() - untrackFactoryCreate() + transaction.finishWrapper() } } /** - * Resume an agent on a persisted session ({@link AgentFactory}). Loads the - * session log + metadata via `ctx.sessionPersistence`, reconstructs the live - * session with the loaded events (so `lastTurnNumber`/`deriveMessages` - * continue), and starts a fresh agent on it. The live session id is the - * resumed id, NOT `${agentId}-session`. - * - * Requires `ctx.sessionPersistence`; rejects with a clear error if it is not - * configured. NOT hard-injected (that would make non-persistent demos pend - * forever) — callers that need resume (ACP) inject `sessionPersistence`, so - * by the time this runs the service exists. - * @param ownerCtx - the caller context that owns load, setup, and the live lifecycle. - * @param options - the persisted session id to reload, plus agent id/options. - * @returns the handle for the agent resumed on the reconstructed session. + * Resume an owned agent from the configured persistence service. + * @param ownerCtx - caller context that owns load, setup, and the live lifecycle. + * @param options - persisted identity, loop options, setup, and cancellation. + * @returns the published handle. */ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise { - this.assertFactoryActive() - // Read the service through `ctx.get('sessionPersistence')` — a direct - // global-store lookup keyed by the isolate symbol — NOT - // `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject - // `sessionPersistence` (injecting it would pend non-persistent demos - // forever). The `ctx.` property proxy resolves a service by an - // ancestor-only walk of the current fiber's parent chain; from AgentLoop's - // own fiber (which lacks the inject) that walk never reaches the sibling - // backend fiber and throws "cannot get property … without inject". Worse, - // when the call arrives via a traceable shadow (e.g. the ACP bridge child - // fiber → `ctx.agents.resume()` → `this.factory.resume()`), the walk starts - // at the shadow's origin fiber and fails the same way. `ctx.get(name)` - // sidesteps the fiber walk entirely (a store lookup by the global isolate - // key), so resume works from any caller fiber. It is strict by default: a - // backend that is not ACTIVE (absent, or mid-teardown) reads as undefined - // and we reject below, rather than handing back an unusable handle. - const persistence = this.ctx.get('sessionPersistence') + const persistence = this.runtime.ctx.get('sessionPersistence') if (persistence === undefined) { throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)') } return this.resumeWith(ownerCtx, persistence, options) } - /** - * Resume against an EXPLICIT persistence handle. Factored out of {@link resume} - * so the config-driven path can pass the handle it obtained from a - * `ctx.inject(['sessionPersistence'], …)` child context: `this.ctx` (the - * service's own fiber) did not inject `sessionPersistence`, so reading it - * there from inside the inject child trips the cordis inject guard. The - * sessions store + registry are still read through `this.ctx` (both are in - * AgentLoop's static inject, so they resolve fine). - */ - private async resumeWith(ownerCtx: Context, persistence: SessionPersistence, options: ResumeAgentOptions): Promise { - // Persistence is an async trust boundary. Reserve, load, reconstruct, and - // publish only the identities/options accepted at entry—never fields - // reread from a caller-owned object after the await. - const agentId = options.agentId - const sessionId = options.resumeSessionId - const agentOptions = structuredClone(options.agentOptions ?? {}) - const setup = options.setup - // Caller-owned accessors above are a synchronous reentrancy boundary: one - // can begin factory unload while options are snapshotted. Re-check before - // installing either ownership sentinel, so a rejected transaction leaves - // no orphan effect or unresolved settlement promise. - this.assertFactoryActive() - const { promise: ownerDisposed, resolve: markOwnerDisposed } = Promise.withResolvers() - const { promise: transactionSettled, resolve: markTransactionSettled } = Promise.withResolvers() - let observingOwner = true - // Resume must observe its caller from BEFORE persistence I/O begins. The - // full agent lifecycle does not exist until load returns, so without this - // sentinel a never-settling backend outlives owner disposal and holds both - // public identities forever. The caller-bound effect retains the same owner - // later used by startOwned's lifecycle effect and adopts both reservation - // disposers before persistence I/O begins. - let lifecycleBoundary: (() => Promise) | undefined - let disposingForFactory: Promise | undefined - const disposeLoadForFactory = (): Promise => (disposingForFactory ??= (async () => { - markOwnerDisposed() - await transactionSettled - })()) - let untrackFactoryLoad: (() => void) | undefined - let disposeLoadSentinel: (() => Promise | void) | undefined - let loadSentinelRetired = false - const retireLoadSentinel = (): void => { - /* v8 ignore next -- every lifecycle/rollback boundary is memoized and - * invokes its after-quiescence hook once; retain idempotence defensively */ - if (loadSentinelRetired) return - // Disarm the follower before invoking its wrapper: retirement can happen - // from inside the lifecycle it used to follow, so recursing into that - // same boundary here would deadlock final teardown. - loadSentinelRetired = true - observingOwner = false - void disposeLoadSentinel?.() - } - let reservations: RegistrationReservations | undefined - let lifecycleStarted = false - try { - reservations = this.reserve(agentId, sessionId) - const ownedReservations = reservations - // Move both reservation effects under a sentinel BEFORE persistence I/O. - // Its first teardown stage either aborts/waits for the load transaction - // or follows the full lifecycle after handoff; only then do the exact - // reservation disposers run. They therefore cannot race ahead as owner - // siblings and reopen ids while load/setup/scope cleanup is still live. - disposeLoadSentinel = ownerCtx.effect(function* () { - // eslint-disable-next-line @typescript-eslint/unbound-method -- exact effect-disposer identity is the ownership contract - yield ownedReservations.agent.release - // eslint-disable-next-line @typescript-eslint/unbound-method -- exact effect-disposer identity is the ownership contract - yield ownedReservations.session.release - yield () => { - if (loadSentinelRetired) return - if (observingOwner) { - markOwnerDisposed() - return transactionSettled - } - return lifecycleBoundary?.() - } - }, `agentLoop.resumeLoad(${agentId})`) - untrackFactoryLoad = this.trackFactoryTransaction(disposeLoadForFactory) - try { - const loadTask = persistence.load(sessionId) - const { meta, events } = await Promise.race([ - loadTask, - ownerDisposed.then(() => { - throw new Error(`agent "${agentId}" resume aborted: owner disposed during persistence load`) - }), - ]) - // The backend is an async boundary too. Read each loaded header field - // once so a stateful implementation cannot pass a valid presence check - // and then substitute a different value during reconstruction. - const createdAt = meta.createdAt - const cwd = meta.cwd - const parentSession = meta.parentSession - const seedLength = meta.seedLength - // An out-of-band direct registry/session insertion can still race this - // service's reservation, so the public enter primitives re-check exact - // liveness at publication. - const session = reservations.session.prepare({ - seed: events, - meta: { - createdAt, - ...cwd !== undefined ? { cwd } : {}, - ...parentSession !== undefined ? { parentSession } : {}, - ...seedLength !== undefined ? { seedLength } : {}, - }, - }) - // startOwned synchronously returns either the complete lifecycle or a - // preparation-rollback boundary before its result reaches the first - // setup await. Retarget the lifecycle-long load sentinel to that disposer; - // ownership overlaps instead of creating a gap. - lifecycleStarted = true - const starting = this.startOwned( - ownerCtx, - agentId, - agentOptions, - session, - 'resume', - reservations, - setup, - retireLoadSentinel, - ) - lifecycleBoundary = starting.dispose - observingOwner = false - return await starting.result - } finally { - if (!lifecycleStarted) reservations.release() - } - } finally { - try { - // Manual handoff/removal must not return transactionSettled: awaiting - // that promise from inside this transaction would deadlock it. If the - // owner already triggered cleanup, this idempotent second disposal is a - // no-op and the owner's first cleanup remains parked on the shared - // settlement promise. - if (!lifecycleStarted) { - // Covers reserve succeeding but sentinel/factory tracking failing - // before the inner load transaction begins. - reservations?.release() - // A failed pre-lifecycle transaction has already released directly; - // retire the sentinel so it cannot remain as a stale owner effect. - retireLoadSentinel() - await disposeLoadSentinel?.() - } - } finally { - markTransactionSettled() - untrackFactoryLoad?.() - } - } - } - - /** Reserve both public identities in their owning registries. */ - private reserve(agentId: AgentId, sessionId: SessionId): RegistrationReservations { - const agent = this.ctx.agents.reserve(agentId) - try { - const session = this.ctx.sessions.reserve(sessionId) - return { - agent, - session, - release() { - // Both owner capabilities are independently idempotent, so the - // composite needs no second state machine of its own. - session.release() - agent.release() - }, - } - } catch (error: unknown) { - agent.release() - throw error - } - } - - /** - * Construct an unpublished agent and synchronously install its complete - * teardown skeleton before any setup await. A lifecycle-long caller sentinel and - * factory placeholder exist before driver/scope construction; the closures - * receive their session/registry/loop disposers only at publication, while - * the exact scope disposer is nested as soon as construction returns. Owner - * unload during preparation or setup therefore follows a real rollback - * boundary, flips liveness, and wins without late Cordis effect collection. - */ - private prepareLifecycle( + /** Resume through an explicit persistence handle used by the deferred config path. */ + private async resumeWith( ownerCtx: Context, - id: AgentId, - options: AgentOptions, - session: Session, - reservations: RegistrationReservations, - afterQuiescence?: () => void, - ): { - agent: ReactLoopAgent - active: () => boolean - deactivated: Promise - publish: (source: SessionStartSource) => void - disposeAgent: () => Promise - } { - // When creation is invoked through an agent scope (subagents), the owner - // agent's disposed status flips synchronously at handle teardown—earlier - // than Cordis reaches nested scope effects. Include that signal in the - // pre-publication liveness check so a same-turn parent dispose cannot race - // an already-fulfilled setup promise into briefly publishing a child. - let ownerAgent: Context['agent'] - let ownerFiber: Context['fiber'] - try { - this.assertFactoryActive() - ownerCtx.fiber.assertActive() - ownerAgent = ownerCtx.agent - ownerFiber = ownerCtx.fiber - } catch (error: unknown) { - reservations.release() - afterQuiescence?.() - const dispose = (): Promise => Promise.resolve() - throw new LifecyclePreparationFailure(error, dispose) - } - - // Establish BOTH ownership edges before driver preparation or scope - // minting can publish an internal lifecycle notification. The lifecycle-long - // caller sentinel also adopts the exact reservation effects: owner unload - // first waits for the memoized lifecycle boundary, then reaches those - // capabilities, so IDs cannot reopen while scope cleanup is still live. - const { promise: lifecycleReady, resolve: markLifecycleReady } - = Promise.withResolvers<() => Promise>() - const { promise: deactivated, resolve: markDeactivated } = Promise.withResolvers() - let ownerDisposed = false - const ownerIsDisposed = (): boolean => ownerDisposed - let ownerSentinelRetired = false - let disposeOwnerSentinel: () => Promise | void - try { - disposeOwnerSentinel = ownerCtx.effect(function* () { - // eslint-disable-next-line @typescript-eslint/unbound-method -- exact effect-disposer identity is the ownership contract - yield reservations.agent.release - // eslint-disable-next-line @typescript-eslint/unbound-method -- exact effect-disposer identity is the ownership contract - yield reservations.session.release - yield () => { - if (ownerSentinelRetired) return - ownerDisposed = true - markDeactivated() - return lifecycleReady.then(disposeLifecycle => disposeLifecycle()) - } - }, `agentLoop.ownerLifecycle(${id})`) - } catch (error: unknown) { - reservations.release() - afterQuiescence?.() - const dispose = (): Promise => Promise.resolve() - markLifecycleReady(dispose) - // The only callback-free effect-install failure is Cordis's inactive - // owner boundary; preserve the original value as cause for diagnostics. - const reportedError = new Error(`agent "${id}" setup aborted: owner disposed during setup`, { cause: error }) - throw new LifecyclePreparationFailure(reportedError, dispose) - } - let disposingForFactory: Promise | undefined - const disposeForFactory = (): Promise => (disposingForFactory ??= (async () => { - const disposeLifecycle = await lifecycleReady - await disposeLifecycle() - })()) - let untrackFactory: () => void - try { - untrackFactory = this.trackFactoryTransaction(disposeForFactory) - } catch (error: unknown) { - /* v8 ignore start -- no callback boundary exists between the active - * factory check, sentinel installation, and this synchronous ledger insert */ - let cleanupTask: Promise | undefined - const cleanup = (): Promise => (cleanupTask ??= Promise.resolve().then(() => { - reservations.release() - afterQuiescence?.() - })) - markLifecycleReady(cleanup) - void disposeOwnerSentinel() - throw new LifecyclePreparationFailure(error, cleanup) - /* v8 ignore stop */ - } - - let scope: Scope | undefined - let stopPrepared: (() => Promise | void) | undefined - let disposeAgent: (() => Promise) | undefined - try { - const driver = prepareReactLoopAgent(this.ctx, id, options, session) - stopPrepared = () => driver.dispose() - const { agent } = driver - scope = createScope(this.ctx, agent) - const lifecycleScope = scope - if (ownerIsDisposed() || !this.factoryIsActive() - || ownerFiber.state === FiberState.UNLOADING - || ownerFiber.state === FiberState.DISPOSED - || ownerFiber.state === FiberState.FAILED - || ownerAgent?.status === 'disposed') { - throw new Error(`agent "${id}" setup aborted: owner disposed during setup`) - } - bindReactLoopAgentContext(agent, lifecycleScope.ctx.extend({ agent })) - - let active = true - let detachSession: (() => void) | undefined - let detachAgent: (() => void) | undefined - const stop = stopPrepared - const { promise: torndown, resolve: markTorndown } = Promise.withResolvers() - const { promise: publicationSettled, resolve: markPublicationSettled } = Promise.withResolvers() - let publishing = false - - const dispose = ownerCtx.effect(function* () { - // First yielded, disposed last: every preceding teardown stage settled. - yield () => { - // Reservation ownership is part of lifecycle settlement: a factory - // unload that awaited this disposer may reuse both ids immediately. - reservations.release() - // Retire both follower effects only after quiescence reached this final - // stage. Their retired branches skip recursively disposing this same - // lifecycle while their exact reservation children are already inert. - ownerSentinelRetired = true - void disposeOwnerSentinel() - afterQuiescence?.() - untrackFactory() - markTorndown() - } - // Exact identity moves the scope fiber out of the owner's concurrent - // sibling list and into this ordered transaction. - yield lifecycleScope.rawDispose - yield () => { - detachSession?.() - detachSession = undefined - } - yield () => { - detachAgent?.() - detachAgent = undefined - } - // Last yielded, disposed first. Keep the pre-publication path - // synchronous: returning a Promise only after the loop actually began - // lets a failed announcement roll back registry/store before create's - // rejection is observed. - yield () => { - active = false - markDeactivated() - // A listener can begin owner teardown reentrantly. Flip liveness now - // so publish's next checkpoint aborts, but keep both registry entries - // and the scope intact until the current synchronous publication - // phase has unwound. - if (publishing) return publicationSettled.then(stop) - return stop() - } - }, 'agentLoop.lifecycle()') - - let disposing: Promise | undefined - disposeAgent = (): Promise => (disposing ??= (async () => { - await dispose() - await torndown - })()) - markLifecycleReady(disposeAgent) - - const isActive = (): boolean => active - && !ownerIsDisposed() - && this.factoryIsActive() - && ownerFiber.state !== FiberState.UNLOADING - && ownerFiber.state !== FiberState.DISPOSED - && ownerFiber.state !== FiberState.FAILED - && ownerAgent?.status !== 'disposed' - - const publish = (source: SessionStartSource): void => { - publishing = true - try { - /* v8 ignore next 3 -- both callers check active immediately before - * this callback-free synchronous publish entry */ - if (!isActive()) { - throw new Error(`agent "${id}" setup aborted: owner disposed during setup`) - } - // Publication is one synchronous, rollback-covered sequence. Setup has - // already completed, so its scoped listeners observe both announcements. - detachSession = agent.ctx.sessions.enter(session, reservations.session) - detachAgent = this.ctx.agents.enter(agent, reservations.agent) - // Both enter() calls capture stable dispatch carriers and therefore - // evaluate a caller-owned Context.filter. A getter can begin teardown; - // entries exist for rollback, but no creation edge may escape afterward. - if (!isActive()) { - throw new Error(`agent "${id}" setup aborted: owner disposed during setup`) - } - this.ctx.sessions.announce(session) - // Session listeners can dispose an owner. Finish that dispatch while - // both entries/scope remain live, then skip the agent edge entirely. - if (!isActive()) { - throw new Error(`agent "${id}" setup aborted: owner disposed during setup`) - } - this.ctx.agents.announce(agent) - // Creation listeners may synchronously dispose either owner. Cordis - // flips the relevant fiber state before it invokes nested effects, so - // re-check here and never unlock a driver after teardown began. - if (!isActive()) { - throw new Error(`agent "${id}" setup aborted: owner disposed during setup`) - } - // Setup is over and both entries are live. Open the driving surface just - // before session-start so its listeners retain their supported ability to - // inject/queue, while setup itself can never drive an unpublished agent. - driver.enableDrive() - agentEvents(this.ctx, agent).emit('agent/session-start', source) - // session-start is the final synchronous listener boundary before the - // loop begins. Teardown there must win just like teardown from either - // creation announcement; the prebuilt driver disposer makes rollback - // quiescent even though the loop never started. - if (!isActive()) { - throw new Error(`agent "${id}" setup aborted: owner disposed during setup`) - } - driver.startDriver() - } finally { - publishing = false - markPublicationSettled() - } - } - - return { - agent, - active: isActive, - deactivated, - publish, - disposeAgent, - } - } catch (error: unknown) { - // Preparation failed before startOwned received a lifecycle object. Give - // a factory unload that already captured the placeholder a real boundary, - // and retire the entry only after the minted scope (if any) is quiescent. - const failedScope = scope - const ownershipInactive = ownerIsDisposed() || ownerFiber.uid === null || !this.factoryIsActive() - || ownerFiber.state === FiberState.UNLOADING - || ownerFiber.state === FiberState.DISPOSED - || ownerFiber.state === FiberState.FAILED - || ownerAgent?.status === 'disposed' - const reportedError = ownershipInactive && error instanceof CordisError - ? new Error(`agent "${id}" setup aborted: owner disposed during setup`, { cause: error }) - : error - let fallbackTask: Promise | undefined - const cleanup = disposeAgent ?? (() => (fallbackTask ??= (async () => { - try { - await stopPrepared?.() - } finally { - try { - await failedScope?.dispose() - } finally { - // Factory and caller quiescence include the prepared driver, - // minted scope, and both unpublished identities even when the - // complete lifecycle effect could not be installed. - reservations.release() - afterQuiescence?.() - } - } - })())) - markLifecycleReady(cleanup) - const cleanupTask = cleanup() - // Retire the provisional owner edge. If owner unload already claimed it, - // this is an inert repeat and that first caller is following cleanupTask. - void disposeOwnerSentinel() - void cleanupTask.then( - untrackFactory, - /* v8 ignore next -- Scope.dispose is specified to contain child - * failures; preserve diagnostics if that lower-level contract breaks */ - (cleanupError: unknown) => { - untrackFactory() - try { - this.ctx.logger.error(new AggregateError([reportedError, cleanupError], 'agent lifecycle preparation and rollback failed')) - } catch { - // Only a logger-export failure is swallowed: the original - // preparation error is already propagating to the caller. - } - }, - ) - throw new LifecyclePreparationFailure(reportedError, cleanup) - } - } - - /** Publish a no-setup config agent synchronously. */ - private start( - id: AgentId, - options: AgentOptions, - session: Session, - source: SessionStartSource, - reservations: RegistrationReservations, - ): { agent: ReactLoopAgent; disposeAgent: () => Promise } { - let lifecycle: ReturnType - try { - lifecycle = this.prepareLifecycle(this.ctx, id, options, session, reservations) - } catch (error: unknown) { - /* v8 ignore next -- prepareLifecycle converts every failure into its - * rollback-bearing internal error before crossing this boundary */ - if (!(error instanceof LifecyclePreparationFailure)) throw error - void error.dispose() - throw error.reason - } - try { - lifecycle.publish(source) - return { agent: lifecycle.agent, disposeAgent: lifecycle.disposeAgent } - } catch (error: unknown) { - void lifecycle.disposeAgent() - throw error - } - } - - /** - * Build an {@link AgentHandle} for a PREPARED session + a fresh agent. The - * handle's `dispose()` runs the composite effect's disposer (see - * {@link start}) — which stops the loop, awaits its exit and outstanding - * idle-injection flushes, unregisters the agent, detaches the session, - * unwinds the scope, and releases both ids, in that order. Caller-fiber unload - * also invokes an independent sentinel that follows this memoized boundary, - * so handle-first and owner-first races honor the same ordering. - * - * `dispose()` is MEMOIZED: the underlying cordis effect disposer is - * single-shot (a second call returns immediately because the effect's epoch is - * already cleared, NOT awaiting the in-flight teardown), so concurrent/repeated - * `dispose()` calls would otherwise resolve before the first call's - * loop + flush quiescence boundary completed. Memoizing the promise makes - * every caller observe that SAME boundary, honoring the - * `AgentHandle.dispose(): Promise` contract (mirrors the ACP `quiesce()` - * helper). - */ - private startOwned( - ownerCtx: Context, - id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource, - reservations: RegistrationReservations, - setup?: (agentCtx: Context) => Promise | void, - afterQuiescence?: () => void, - ): OwnedAgentStart { - let lifecycle: ReturnType - try { - lifecycle = this.prepareLifecycle(ownerCtx, id, options, session, reservations, afterQuiescence) - } catch (error: unknown) { - /* v8 ignore next 1 -- prepareLifecycle wraps every synchronous failure */ - if (!(error instanceof LifecyclePreparationFailure)) throw error - return { - dispose: error.dispose, - result: (async () => { - await error.dispose() - throw error.reason - })(), - } - } - return { - dispose: lifecycle.disposeAgent, - result: this.finishOwnedStart(lifecycle, id, source, setup), - } - } - - /** Await setup and publish after {@link startOwned} established ownership synchronously. */ - private async finishOwnedStart( - lifecycle: ReturnType, - id: AgentId, - source: SessionStartSource, - setup?: (agentCtx: Context) => Promise | void, + persistence: SessionPersistence, + options: ResumeAgentOptions, ): Promise { + const transaction = new AgentCreationTransaction( + this.runtime.ctx, + ownerCtx, + this.ownership, + options.agentId, + options.signal, + ) try { - // Scope minting emits Cordis's synchronous internal/plugin notification. - // A listener can unload either owner there; never run arbitrary setup in - // the already-doomed scope while the tracked disposer is catching up. - /* v8 ignore next 3 -- prepareLifecycle returns success only after its - * final synchronous liveness check; no callback runs before this line */ - if (!lifecycle.active()) { - throw new Error(`agent "${id}" setup aborted: owner disposed during setup`) - } - // The owner-disposal branch makes a never-settling setup unable to hold - // the transaction or its ID reservations forever. Promise.race installs - // rejection observation on setup even if owner disposal wins first. - const setupTask = Promise.resolve(setup?.(lifecycle.agent.ctx)) - await Promise.race([ - setupTask, - lifecycle.deactivated.then(() => { - throw new Error(`agent "${id}" setup aborted: owner disposed during setup`) - }), - ]) - // Cordis begins a fiber unload synchronously but invokes nested effect - // disposers from its next microtask. Give that already-started unload one - // checkpoint to deactivate this lifecycle before publication; otherwise - // an immediately fulfilled setup continuation can outrun its owner's - // same-turn dispose and briefly publish an already-doomed child. - await Promise.resolve() - if (!lifecycle.active()) { - throw new Error(`agent "${id}" setup aborted: owner disposed during setup`) - } - lifecycle.publish(source) - return { agent: lifecycle.agent, dispose: lifecycle.disposeAgent } + const loaded = await transaction.waitFor(persistence.load(options.resumeSessionId)) + transaction.assertActive() + const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, { + seed: loaded.events, + meta: { + createdAt: loaded.meta.createdAt, + ...loaded.meta.cwd === undefined ? {} : { cwd: loaded.meta.cwd }, + ...loaded.meta.parentSession === undefined ? {} : { parentSession: loaded.meta.parentSession }, + ...loaded.meta.seedLength === undefined ? {} : { seedLength: loaded.meta.seedLength }, + }, + }) + const agent = transaction.prepare(options.agentOptions ?? {}, session) + await transaction.waitFor(options.setup?.(agent.ctx)) + transaction.assertActive() + return transaction.publish('resume') } catch (error: unknown) { - await lifecycle.disposeAgent() + await transaction.dispose(error instanceof Error ? error : new Error(String(error))) throw error + } finally { + transaction.finishWrapper() } } } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 982fa02cab..7a7ca2e28c 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -11,7 +11,7 @@ import type { Context } from 'cordis' import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm' import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm' import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' -import type { AgentEventDispatch, ContinuationDecision, ContinuationStop, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import { createTransmissionLog, recordRequestHeader } from './request-log.ts' @@ -36,20 +36,6 @@ function toError(error: unknown): CodedError { return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error }) } -/** - * Validate the runtime result of the terminal-stop serial event. Event types - * protect TypeScript listeners, but JavaScript and casts can still return an - * arbitrary bail value; accepting one as an implicit stop would hide a broken - * policy plugin. - */ -function assertContinuationStop(value: unknown): asserts value is ContinuationStop | undefined { - if (value === undefined) return - const candidate = Object(value) as { action?: unknown } - if (candidate.action !== 'stop') { - throw new Error('agent/turn-stop returned an invalid result; expected { action: \'stop\' } or undefined') - } -} - /** * Map a model-call {@link FinishReason} to the step error it should raise, or * `undefined` when the step completed normally. @@ -644,8 +630,7 @@ async function runTurn( // no later listener or steering override can resurrect the turn. let terminalStop = false try { - const stop = await events.strictSerial('agent/turn-stop', turn) - assertContinuationStop(stop) + const stop = await events.serial('agent/turn-stop', turn) terminalStop = stop !== undefined } catch (error: unknown) { // A broken terminal policy is an ordinary continuation failure: fail diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index d132d862a8..95b5440fe1 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -49,30 +49,15 @@ function send(agent: ReactLoopAgent, text: string) { } describe('ReactLoopAgent', () => { - it('owns immutable runtime bindings for id, options, session, and scoped context', async () => { + it('borrows caller options and binds its scoped context exactly once', async () => { const ctx = await harness(new MockAdapter([textResponse('unused')])) const options = { model: 'mock' } const agent = ctx.agentLoop.create(AgentId('owned-bindings'), options) - const acceptedSession = agent.session - const acceptedContext = agent.ctx - options.model = 'caller-mutated' - expect(agent.options).toEqual({ model: 'mock' }) - expect(Object.isFrozen(agent.options)).toBe(true) - expect(Reflect.set(agent, 'id', AgentId('redirected'))).toBe(false) - expect(Reflect.set(agent, 'options', { model: 'other' })).toBe(false) - expect(Reflect.set(agent, 'session', ctx.sessions.create(SessionId('other')))).toBe(false) - expect(Reflect.set(agent, 'ctx', new Context())).toBe(false) + expect(agent.options).toBe(options) expect(agent.id).toBe('owned-bindings') - expect(agent.session).toBe(acceptedSession) - expect(agent.ctx).toBe(acceptedContext) + expect(agent.session.id).toMatch(/^owned-bindings-session-/) expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/) - for (const name of ['id', 'options', 'session', 'ctx']) { - expect(Object.getOwnPropertyDescriptor(agent, name)).toMatchObject({ - configurable: false, - writable: false, - }) - } await ctx.fiber.dispose() }) @@ -223,23 +208,6 @@ describe('ReactLoopAgent', () => { warn.mockRestore() }) - it('idle inject() safely renders a hostile non-Error flush failure', async () => { - const ctx = await harness(new MockAdapter([textResponse('ok')])) - const hostile = { [Symbol.toPrimitive]() { throw new Error('no coercion') } } - ctx.on('session/flush', () => { throw hostile }) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(AgentId('hostile-flush'), { model: 'mock' }) - const errors: string[] = [] - ctx.on('agent/error', (_a, _turn, _step, error) => void errors.push(error.message)) - - agent.inject([{ type: 'text', text: 'notice' }]) - await new Promise(r => setTimeout(r, 20)) - - expect(errors).toEqual(['']) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('')) - warn.mockRestore() - }) - it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) @@ -281,7 +249,7 @@ describe('ReactLoopAgent', () => { // Start the loop to get the disposer; the agent waits for messages // (idle, never-resolving cancel), so it will stay idle. - prepared.enableDrive() + prepared.markPublished() const dispose = prepared.startDriver() // First dispose @@ -309,24 +277,6 @@ describe('ReactLoopAgent', () => { await ctx.fiber.dispose() }) - it('does not claim a session when concrete-agent construction rejects options', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('constructor-retry')) - const badOptions = { - get model(): string { - throw new Error('bad model getter') - }, - } - - expect(() => prepareReactLoopAgent(ctx, AgentId('bad-constructor'), badOptions, session)) - .toThrow('bad model getter') - const prepared = prepareReactLoopAgent(ctx, AgentId('constructor-retry'), { model: 'mock' }, session) - await prepared.dispose() - expect(prepared.agent.status).toBe('disposed') - await ctx.fiber.dispose() - }) - it('setting the same status does not emit agent/status again', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) @@ -417,7 +367,7 @@ describe('ReactLoopAgent', () => { const session = ctx.sessions.create(SessionId('bare')) const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session) const { agent } = prepared - prepared.enableDrive() + prepared.markPublished() const dispose = prepared.startDriver() agent.send([{ type: 'text', text: 'go' }]) await new Promise(r => setTimeout(r, 30)) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index d739ff0ed3..2c9fc78177 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' @@ -99,22 +99,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.fiber.dispose() }) - it('createAgent sends raw metadata to the session validator before cloning can sanitize it', async () => { - class ExoticMeta { - readonly cwd = '/accepted' - } - const { ctx } = await persistentHarness(new MockAdapter([textResponse('hi')])) - - await expect(ctx.agents.create({ - agentId: AgentId('exotic-meta-agent'), - sessionId: SessionId('exotic-meta-session'), - meta: new ExoticMeta(), - })).rejects.toThrow(/session metadata is not a plain JSON record/) - expect(ctx.agents.get(AgentId('exotic-meta-agent'))).toBeUndefined() - expect(ctx.sessions.get(SessionId('exotic-meta-session'))).toBeUndefined() - await ctx.fiber.dispose() - }) - it('resume of a session with no cwd carries an undefined cwd header', async () => { // Lifecycle 1: create a no-cwd session and run a turn. const adapter1 = new MockAdapter([textResponse('a')]) @@ -184,7 +168,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { order.push('session/created') }) ctx.on('agent/created', (agent) => { - expect(() => { agent.cancel('too early') }).toThrow(/cannot cancel before creation setup completes/) + expect(agent.status).toBe('idle') order.push('agent/created') }) ctx.on('agent/session-start', (agent) => { @@ -228,9 +212,9 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.fiber.dispose() }) - it('successful resume disposal retires both caller ownership sentinels', async () => { - const sessionId = SessionId('resume-retired-sentinels-s') - const agentId = AgentId('resume-retired-sentinels') + it('successful resume disposal retires its caller-owned transaction effects', async () => { + const sessionId = SessionId('resume-retired-effects-s') + const agentId = AgentId('resume-retired-effects') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) const handle = await ctx.agents.resume({ @@ -238,14 +222,14 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { resumeSessionId: sessionId, agentOptions: { model: 'mock' }, }) - const sentinelLabels = [ - `agentLoop.resumeLoad(${agentId})`, - `agentLoop.ownerLifecycle(${agentId})`, + const transactionLabels = [ + `agentLoop.owner(${agentId})`, + `agentLoop.lifecycle(${agentId})`, ] - expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(sentinelLabels)) + expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(transactionLabels)) await handle.dispose() - expect(ctx.fiber.getEffects().filter(effect => sentinelLabels.includes(effect.label))).toEqual([]) + expect(ctx.fiber.getEffects().filter(effect => transactionLabels.includes(effect.label))).toEqual([]) await ctx.fiber.dispose() }) @@ -346,14 +330,14 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { }, { inject: ['agents'] })) await loadStarted.promise - const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during persistence load/) + const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/) await promptly(owner.dispose()) expect(published).toEqual([]) expect(ctx.agents.get(agentId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() - // owner.dispose() itself awaited transaction settlement and reservation - // release: reuse the same identities BEFORE awaiting the resume rejection. + // owner.dispose() awaited transaction settlement, so the same identities + // can be reused before awaiting the public rejection. const retry = await promptly(ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } })) await rejection expect(loads).toBe(2) @@ -372,7 +356,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.fiber.dispose() }) - it('AgentLoop unload aborts persistence load and awaits reservation release', async () => { + it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => { const sessionId = SessionId('resume-load-factory-unload') const agentId = AgentId('resume-load-factory-race') const root = await persistSession(sessionId) @@ -400,18 +384,13 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { const resuming = ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } }) await loadStarted.promise - const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during persistence load/) + const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/) await promptly(loopFiber.dispose()) await rejection expect(published).toEqual([]) expect(ctx.agents.get(agentId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() - const agentReservation = ctx.agents.reserve(agentId) - const sessionReservation = ctx.sessions.reserve(sessionId) - sessionReservation.release() - agentReservation.release() - lateLoad.resolve(structuredClone(snapshot)) await Promise.resolve() await Promise.resolve() @@ -419,83 +398,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.fiber.dispose() }) - it('option snapshot reentrancy cannot install a resume sentinel after factory unload begins', async () => { - const sessionId = SessionId('resume-snapshot-factory-unload') - const agentId = AgentId('resume-snapshot-factory-race') - const root = await persistSession(sessionId) - const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SessionPersistenceJsonl, { root }) - ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')])) - - let loads = 0 - const load = ctx.sessionPersistence.load.bind(ctx.sessionPersistence) - ctx.sessionPersistence.load = (id) => { - loads += 1 - return load(id) - } - const options = { - agentId, - resumeSessionId: sessionId, - get agentOptions() { - void loopFiber.dispose() - return { model: 'mock' } - }, - } - - await expect(ctx.agents.resume(options)).rejects.toThrow('agent loop is not active') - await loopFiber.dispose() - expect(loads).toBe(0) - expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.resumeLoad(${agentId})`)).toEqual([]) - const agentReservation = ctx.agents.reserve(agentId) - const sessionReservation = ctx.sessions.reserve(sessionId) - sessionReservation.release() - agentReservation.release() - await ctx.fiber.dispose() - }) - - it('snapshots resume identities and agent options before persistence load', async () => { - const sessionId = SessionId('resume-snapshot-source') - const root = await persistSession(sessionId) - const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) - const loaded = await ctx.sessionPersistence.load(sessionId) - const loadGate = Promise.withResolvers() - ctx.sessionPersistence.load = () => loadGate.promise - - const occupied = await ctx.agents.create({ - agentId: AgentId('occupied-agent'), - sessionId: SessionId('occupied-session'), - agentOptions: { model: 'mock' }, - }) - const options = { - agentId: AgentId('accepted-agent'), - resumeSessionId: sessionId, - agentOptions: { model: 'mock' }, - } - const resuming = ctx.agents.resume(options) - - options.agentId = AgentId('occupied-agent') - options.resumeSessionId = SessionId('occupied-session') - options.agentOptions.model = 'mutated-model' - loadGate.resolve(structuredClone(loaded)) - - const resumed = await resuming - expect(resumed.agent.id).toBe(AgentId('accepted-agent')) - expect(resumed.agent.session.id).toBe(sessionId) - expect(resumed.agent.options.model).toBe('mock') - expect(ctx.agents.get(AgentId('occupied-agent'))).toBe(occupied.agent) - expect(ctx.sessions.get(SessionId('occupied-session'))).toBe(occupied.agent.session) - - await resumed.dispose() - await occupied.dispose() - await ctx.fiber.dispose() - }) - it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => { // Lifecycle 1: persist a FORKED session (carries parentSession + seedLength // in its header) by creating it with a complete-turn seed — the write path @@ -535,54 +437,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.fiber.dispose() }) - it('reads each loaded metadata field once before reconstructing a resumed session', async () => { - const sessionId = SessionId('resume-loaded-meta-once') - const root = await persistSession(sessionId) - const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) - const loaded = await ctx.sessionPersistence.load(sessionId) - const reads = { createdAt: 0, cwd: 0, parentSession: 0, seedLength: 0 } - const meta = Object.defineProperties({ - version: loaded.meta.version, - id: loaded.meta.id, - }, { - createdAt: { - enumerable: true, - get: () => { reads.createdAt += 1; return reads.createdAt === 1 ? loaded.meta.createdAt : 1n }, - }, - cwd: { - enumerable: true, - get: () => { reads.cwd += 1; return reads.cwd === 1 ? '/loaded' : 'relative' }, - }, - parentSession: { - enumerable: true, - get: () => { reads.parentSession += 1; return reads.parentSession === 1 ? SessionId('parent') : 1n }, - }, - seedLength: { - enumerable: true, - get: () => { reads.seedLength += 1; return reads.seedLength === 1 ? 0 : 1n }, - }, - }) as unknown as SessionHeader - ctx.sessionPersistence.load = () => Promise.resolve({ meta, events: loaded.events }) - - const resumed = await ctx.agents.resume({ - agentId: AgentId('resume-loaded-meta-once'), - resumeSessionId: sessionId, - agentOptions: { model: 'mock' }, - }) - - expect(reads).toEqual({ createdAt: 1, cwd: 1, parentSession: 1, seedLength: 1 }) - expect(resumed.agent.session.header).toEqual({ - version: loaded.meta.version, - id: sessionId, - createdAt: loaded.meta.createdAt, - cwd: '/loaded', - parentSession: 'parent', - seedLength: 0, - }) - await resumed.dispose() - await ctx.fiber.dispose() - }) - it('an idle inject() is flushed durably on its own (survives without explicit flush/dispose)', async () => { // Lifecycle 1: run a turn, then inject context while idle. The idle inject // wraps its context/message in a one-shot turn AND checkpoints it (the turn-enclosure RFC) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index d4742da3e6..5898a82ee2 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -443,14 +443,12 @@ describe('MEDIUM: misc registry and config fixes', () => { const source = { kind: 'plugin' as const, plugin: 'accepted-source' } let notifiedContent: ContentBlock[] | undefined let notifiedSource: MessageSource | undefined - let notifiedInfoFrozen = false ctx.on('agent/queued', (subject, acceptedContent, info) => { if (subject !== agent || info.steering) return // Retain the exact notification references: cloning here would test the // listener's copy rather than the event/inbox ownership boundary. notifiedContent = acceptedContent notifiedSource = info.source - notifiedInfoFrozen = Object.isFrozen(info) }) agent.send(content, { source }) @@ -463,7 +461,6 @@ describe('MEDIUM: misc registry and config fixes', () => { expect(Object.isFrozen(notifiedContent)).toBe(true) expect(Object.isFrozen(notifiedContent?.[0])).toBe(true) expect(Object.isFrozen(notifiedSource)).toBe(true) - expect(notifiedInfoFrozen).toBe(true) const recorded = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : []) expect(recorded).toContainEqual({ content: [{ type: 'text', text: 'accepted-send' }], @@ -474,33 +471,6 @@ describe('MEDIUM: misc registry and config fixes', () => { expect(request).not.toContain('caller-mutated-send') }) - it('send() rechecks disposal after materializing caller getters', async () => { - const adapter = new MockAdapter([textResponse('unused')]) - const ctx = await harness(adapter) - const handle = await ctx.agents.create({ - agentId: AgentId('reentrant-send-dispose'), - sessionId: SessionId('reentrant-send-dispose-session'), - agentOptions: { model: 'mock' }, - }) - const { agent } = handle - let queued = 0 - ctx.on('agent/queued', subject => void (queued += Number(subject === agent))) - const content = [{ - type: 'text' as const, - get text() { - void handle.dispose() - return 'accepted-after-dispose' - }, - }] - - expect(() => { agent.send(content) }).toThrow(/agent "reentrant-send-dispose" is disposed/) - await handle.dispose() - - expect(queued).toBe(0) - expect(agent.session.events).toHaveLength(0) - expect(adapter.requests).toHaveLength(0) - }) - it('running steer() owns content and source before notification and delivery', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'gate', {}), textResponse('done')]) const ctx = await harness(adapter) @@ -519,12 +489,10 @@ describe('MEDIUM: misc registry and config fixes', () => { })) let notifiedContent: ContentBlock[] | undefined let notifiedSource: MessageSource | undefined - let notifiedInfoFrozen = false ctx.on('agent/queued', (subject, acceptedContent, info) => { if (subject !== agent || !info.steering) return notifiedContent = acceptedContent notifiedSource = info.source - notifiedInfoFrozen = Object.isFrozen(info) }) agent.send([{ type: 'text', text: 'start' }]) @@ -544,7 +512,6 @@ describe('MEDIUM: misc registry and config fixes', () => { expect(Object.isFrozen(notifiedContent)).toBe(true) expect(Object.isFrozen(notifiedContent?.[0])).toBe(true) expect(Object.isFrozen(notifiedSource)).toBe(true) - expect(notifiedInfoFrozen).toBe(true) const recorded = agent.session.events.flatMap(event => event.type === 'steering/message' ? [event.data] : []) expect(recorded).toContainEqual({ turn: 1, @@ -579,7 +546,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] }) const prepared = prepareReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded) const forked = prepared.agent - prepared.enableDrive() + prepared.markPublished() ctx2.effect(() => prepared.startDriver()) const turns: number[] = [] diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 275fdadb5a..65ebbff8dd 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -8,7 +8,6 @@ import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepse import type { Agent } from '@deepseek-ai/dsh-agent' import { scopeOf } from '@deepseek-ai/dsh-scope' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import * as concreteAgentModule from '../src/agent.ts' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -46,7 +45,7 @@ function disposeCurrentLifecycle(ownerCtx: Context): void { const lifecycle = [...ownerCtx.fiber._disposables] .find((dispose) => { const effect = (dispose as typeof dispose & { [symbols.effect]?: EffectMeta })[symbols.effect] - return effect?.label === 'agentLoop.lifecycle()' + return effect?.label.startsWith('agentLoop.lifecycle(') === true }) if (lifecycle === undefined) throw new Error('agent lifecycle effect not found') void lifecycle() @@ -167,11 +166,9 @@ describe('agent scope lifecycle', () => { expect(ctx.agents.get(AgentId('atomic'))).toBeUndefined() expect(ctx.sessions.get(SessionId('atomic-s'))).toBeUndefined() expect(order).toEqual(['setup:start']) - acceptedOptions.model = 'mutated while setup was pending' - gate.resolve(undefined) const handle = await creating - expect(handle.agent.options.model).toBe('mock') + expect(handle.agent.options).toBe(acceptedOptions) expect(order).toEqual([ 'setup:start', 'setup:end', @@ -184,90 +181,80 @@ describe('agent scope lifecycle', () => { await handle.dispose() }) - it('reserves agent and session ids across concurrent async setup', async () => { + it('lets the final enter arbitrate unsupported concurrent same-id creation and rolls the loser back', async () => { const ctx = await harness() const gate = Promise.withResolvers() + const bothStarted = Promise.withResolvers() + let started = 0 + const setup = async (): Promise => { + started += 1 + if (started === 2) bothStarted.resolve(undefined) + await gate.promise + } + const agentId = AgentId('concurrent-final-enter') const first = ctx.agents.create({ - agentId: AgentId('reserved'), - sessionId: SessionId('reserved-s'), + agentId, + sessionId: SessionId('concurrent-final-enter-a'), agentOptions: { model: 'mock' }, - setup: () => gate.promise, + setup, }) - - await expect(ctx.agents.create({ - agentId: AgentId('reserved'), - sessionId: SessionId('other-s'), + const second = ctx.agents.create({ + agentId, + sessionId: SessionId('concurrent-final-enter-b'), agentOptions: { model: 'mock' }, - })).rejects.toThrow(/already registered/) - await expect(ctx.agents.create({ - agentId: AgentId('other'), - sessionId: SessionId('reserved-s'), - agentOptions: { model: 'mock' }, - })).rejects.toThrow(/already exists/) + setup, + }) + await bothStarted.promise expect(ctx.agents.list()).toEqual([]) expect(ctx.sessions.list()).toEqual([]) gate.resolve(undefined) - const handle = await first - await handle.dispose() + const outcomes = await Promise.allSettled([first, second]) + const fulfilled = outcomes.filter((outcome): outcome is PromiseFulfilledResult> => outcome.status === 'fulfilled') + const rejected = outcomes.filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') + expect(fulfilled).toHaveLength(1) + expect(rejected).toHaveLength(1) + expect(String(rejected[0]!.reason)).toMatch(/already registered/) + expect(ctx.agents.list()).toEqual([fulfilled[0]!.value.agent]) + expect(ctx.sessions.list()).toEqual([fulfilled[0]!.value.agent.session]) + + await fulfilled[0]!.value.dispose() + expect(ctx.agents.list()).toEqual([]) + expect(ctx.sessions.list()).toEqual([]) }) - it('makes setup-time publication structurally impossible through public stores', async () => { + it('uses signal only for creation: aborts pending setup but not a returned live handle', async () => { const ctx = await harness() - const lifecycle: string[] = [] - ctx.on('session/created', () => void lifecycle.push('session')) - ctx.on('agent/created', () => void lifecycle.push('agent')) - - const handle = await ctx.agents.create({ - agentId: AgentId('guarded-publication'), - sessionId: SessionId('guarded-publication-s'), + const pendingController = new AbortController() + const setupStarted = Promise.withResolvers() + const pending = ctx.agents.create({ + agentId: AgentId('signal-pending'), + sessionId: SessionId('signal-pending-s'), agentOptions: { model: 'mock' }, - setup: (agentCtx) => { - const agent = agentCtx.agent! - expect(() => agentCtx.agents.enter(agent)).toThrow(/reserved for unpublished creation/) - expect(() => agentCtx.agents.register(agent)).toThrow(/reserved for unpublished creation/) - expect(() => agentCtx.sessions.enter(agent.session)).toThrow(/reserved for unpublished creation/) - expect(() => agentCtx.sessions.prepare(agent.session.id)).toThrow(/reserved for unpublished creation/) - expect(() => agentCtx.sessions.create(agent.session.id)).toThrow(/reserved for unpublished creation/) - expect(lifecycle).toEqual([]) - expect(ctx.agents.get(agent.id)).toBeUndefined() - expect(ctx.sessions.get(agent.session.id)).toBeUndefined() + signal: pendingController.signal, + setup: async () => { + setupStarted.resolve(undefined) + await new Promise(() => {}) }, }) + await setupStarted.promise + pendingController.abort(new Error('cancel pending creation')) + await expect(pending).rejects.toThrow('cancel pending creation') + expect(ctx.agents.get(AgentId('signal-pending'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('signal-pending-s'))).toBeUndefined() - expect(lifecycle).toEqual(['session', 'agent']) - expect(ctx.agents.get(handle.agent.id)).toBe(handle.agent) - expect(ctx.sessions.get(handle.agent.session.id)).toBe(handle.agent.session) - await handle.dispose() - }) - - it('structurally rejects every driving verb during setup', async () => { - const ctx = await harness() - const handle = await ctx.agents.create({ - agentId: AgentId('no-drive'), - sessionId: SessionId('no-drive-s'), + const liveController = new AbortController() + const live = await ctx.agents.create({ + agentId: AgentId('signal-live'), + sessionId: SessionId('signal-live-s'), agentOptions: { model: 'mock' }, - setup: (agentCtx) => { - const agent = agentCtx.agent! - // Even JavaScript or a cast to the exported concrete class cannot name - // a public start method. Driver startup is behind a module-private - // symbol used only by AgentLoop after rollback-covered publication. - expect(Reflect.get(agent as ReactLoopAgent, 'start')).toBeUndefined() - expect(Reflect.get(concreteAgentModule, 'enableAgentDrive')).toBeUndefined() - expect(Reflect.get(concreteAgentModule, 'startAgentDriver')).toBeUndefined() - expect(() => concreteAgentModule.prepareReactLoopAgent( - agentCtx, agent.id, agent.options, agent.session, - )).toThrow(/already has a concrete agent driver/) - expect(Reflect.get(agent as ReactLoopAgent, 'inbox')).toBeUndefined() - expect(() => { agent.send(text('queued too soon')) }).toThrow(/cannot send before creation setup completes/) - expect(() => { agent.steer(text('steered too soon')) }).toThrow(/cannot steer before creation setup completes/) - expect(() => { agent.inject(text('injected too soon')) }).toThrow(/cannot inject before creation setup completes/) - expect(() => { agent.cancel('cancel too soon') }).toThrow(/cannot cancel before creation setup completes/) - expect(agent.session.events).toEqual([]) - }, + signal: liveController.signal, }) - expect(handle.agent.session.events).toEqual([]) - await handle.dispose() + liveController.abort(new Error('too late')) + await Promise.resolve() + expect(ctx.agents.get(live.agent.id)).toBe(live.agent) + expect(live.agent.status).toBe('idle') + await live.dispose() }) it('owner unload aborts a pending setup and publishes nothing', async () => { @@ -347,16 +334,11 @@ describe('agent scope lifecycle', () => { await setupStarted.promise await loopFiber.dispose() - await expect(creating).rejects.toThrow(/owner disposed during setup/) + await expect(creating).rejects.toThrow(/agent loop is not active/) expect(published).toEqual([]) expect(ctx.agents.get(AgentId('factory-setup-race'))).toBeUndefined() expect(ctx.sessions.get(SessionId('factory-setup-race-s'))).toBeUndefined() - // Factory unload itself reached the reservation-release boundary. - const agentReservation = ctx.agents.reserve(AgentId('factory-setup-race')) - const sessionReservation = ctx.sessions.reserve(SessionId('factory-setup-race-s')) - sessionReservation.release() - agentReservation.release() gate.resolve(undefined) await ctx.fiber.dispose() }) @@ -377,16 +359,12 @@ describe('agent scope lifecycle', () => { agentOptions: { model: 'mock' }, setup: () => { setupCalls += 1 }, }) - await expect(creating).rejects.toThrow(/owner disposed during setup/) + await expect(creating).rejects.toThrow(/agent loop is not active/) await loopFiber.dispose() expect(setupCalls).toBe(0) expect(ctx.agents.get(AgentId('factory-scope-race'))).toBeUndefined() expect(ctx.sessions.get(SessionId('factory-scope-race-s'))).toBeUndefined() - const agentReservation = ctx.agents.reserve(AgentId('factory-scope-race')) - const sessionReservation = ctx.sessions.reserve(SessionId('factory-scope-race-s')) - sessionReservation.release() - agentReservation.release() await ctx.fiber.dispose() }) @@ -444,14 +422,14 @@ describe('agent scope lifecycle', () => { }) expect(() => ctx.agentLoop.create(AgentId('config-scope-race'), { model: 'mock' })) - .toThrow(/owner disposed during setup/) + .toThrow(/agent loop is not active/) await loopFiber.dispose() expect(ctx.agents.get(AgentId('config-scope-race'))).toBeUndefined() expect(ctx.sessions.list()).toHaveLength(sessionsBefore) await ctx.fiber.dispose() }) - it('synchronous create releases both reservations when session preparation fails', async () => { + it('synchronous create leaves no lifecycle state when session preparation fails', async () => { const ctx = await harness() const id = AgentId('config-prepare-failure') @@ -463,44 +441,7 @@ describe('agent scope lifecycle', () => { await ctx.fiber.dispose() }) - it('turns owner disposal from the caller association getter into a rollback boundary', async () => { - const ctx = await harness() - let creating!: ReturnType - let getterCalls = 0 - const creationStarted = Promise.withResolvers() - const owner = ctx.plugin(Object.assign((inner: Context) => { - Object.defineProperty(inner, 'agent', { - configurable: true, - get() { - getterCalls += 1 - void inner.fiber.dispose() - return undefined - }, - }) - creating = inner.agents.create({ - agentId: AgentId('association-dispose'), - sessionId: SessionId('association-dispose-s'), - agentOptions: { model: 'mock' }, - }) - creationStarted.resolve(undefined) - }, { inject: ['agents'] })) - - await creationStarted.promise - await expect(creating).rejects.toThrow(/owner disposed during setup/) - await owner - expect(getterCalls).toBe(1) - expect(ctx.agents.get(AgentId('association-dispose'))).toBeUndefined() - expect(ctx.sessions.get(SessionId('association-dispose-s'))).toBeUndefined() - const replacement = await ctx.agents.create({ - agentId: AgentId('association-dispose'), - sessionId: SessionId('association-dispose-s'), - agentOptions: { model: 'mock' }, - }) - await replacement.dispose() - await ctx.fiber.dispose() - }) - - it('factory unload awaits reservations when reentrant scope preparation throws', async () => { + it('factory unload awaits provisional cleanup when scope preparation throws', async () => { const { ctx, loopFiber } = await harnessWithLoop() let triggered = false ctx.on('internal/plugin', (fiber) => { @@ -519,36 +460,6 @@ describe('agent scope lifecycle', () => { expect(ctx.agents.get(AgentId('factory-scope-throw'))).toBeUndefined() expect(ctx.sessions.get(SessionId('factory-scope-throw-s'))).toBeUndefined() - const agentReservation = ctx.agents.reserve(AgentId('factory-scope-throw')) - const sessionReservation = ctx.sessions.reserve(SessionId('factory-scope-throw-s')) - sessionReservation.release() - agentReservation.release() - await ctx.fiber.dispose() - }) - - it('factory unload during session preparation awaits create reservation release', async () => { - const { ctx, loopFiber } = await harnessWithLoop() - let unloading!: Promise - const meta = { - get cwd() { - unloading = loopFiber.dispose() - return '/factory-unload' - }, - } - - const creating = ctx.agents.create({ - agentId: AgentId('factory-prepare-race'), - sessionId: SessionId('factory-prepare-race-s'), - agentOptions: { model: 'mock' }, - meta, - }) - await unloading - await expect(creating).rejects.toThrow('agent loop is not active') - - const agentReservation = ctx.agents.reserve(AgentId('factory-prepare-race')) - const sessionReservation = ctx.sessions.reserve(SessionId('factory-prepare-race-s')) - sessionReservation.release() - agentReservation.release() await ctx.fiber.dispose() }) @@ -566,14 +477,10 @@ describe('agent scope lifecycle', () => { expect(handle.agent.status).toBe('disposed') expect(ctx.agents.get(agentId)).toBeUndefined() expect(ctx.sessions.get(SessionId('factory-live-s'))).toBeUndefined() - expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.ownerLifecycle(${agentId})`)).toEqual([]) + expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([]) // The consumer handle shares the provider's completed quiescence boundary. await handle.dispose() - const agentReservation = ctx.agents.reserve(agentId) - const sessionReservation = ctx.sessions.reserve(SessionId('factory-live-s')) - sessionReservation.release() - agentReservation.release() await expect(loop.createAgent(ctx, { agentId: AgentId('factory-inactive'), sessionId: SessionId('factory-inactive-s'), @@ -647,7 +554,7 @@ describe('agent scope lifecycle', () => { }) }, { inject: ['agents'] })) - await expect(creating).rejects.toThrow(/owner disposed during setup/) + await expect(creating).rejects.toThrow(/lifecycle disposed/) await owner.dispose() expect(lifecycle).toEqual([ 'session-created:dispose', @@ -696,7 +603,7 @@ describe('agent scope lifecycle', () => { }) }, { inject: ['agents'] })) - await expect(creating).rejects.toThrow(/owner disposed during setup/) + await expect(creating).rejects.toThrow(/lifecycle disposed/) await owner.dispose() expect(lifecycle).toEqual([ 'session-created', @@ -711,52 +618,6 @@ describe('agent scope lifecycle', () => { await ctx.fiber.dispose() }) - it('rechecks owner liveness after carrier capture before the first creation edge', async () => { - const ctx = await harness() - let ownerCtx!: Context - const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['agents'] })) - const agentId = AgentId('carrier-owner-race') - const sessionId = SessionId('carrier-owner-race-s') - const lifecycle: string[] = [] - let filterReads = 0 - ctx.on('session/created', (session) => { - if (session.id === sessionId) lifecycle.push('session-created') - }) - ctx.on('session/disposed', (session) => { - if (session.id === sessionId) lifecycle.push('session-disposed') - }) - ctx.on('agent/created', (agent) => { - if (agent.id === agentId) lifecycle.push('agent-created') - }) - ctx.on('agent/disposed', (agent) => { - if (agent.id === agentId) lifecycle.push('agent-disposed') - }) - - const creating = ownerCtx.agents.create({ - agentId, - sessionId, - agentOptions: { model: 'mock' }, - setup(agentCtx) { - Object.defineProperty(agentCtx.agent!.session, Context.filter, { - configurable: true, - get() { - filterReads += 1 - void owner.dispose() - return undefined - }, - }) - }, - }) - - await expect(creating).rejects.toThrow(/owner disposed during setup/) - await owner.dispose() - expect(filterReads).toBe(1) - expect(lifecycle).toEqual([]) - expect(ctx.agents.get(agentId)).toBeUndefined() - expect(ctx.sessions.get(sessionId)).toBeUndefined() - await ctx.fiber.dispose() - }) - it('rechecks caller liveness after creation listeners before unlocking the driver', async () => { const ctx = await harness() const starts: string[] = [] @@ -817,7 +678,7 @@ describe('agent scope lifecycle', () => { }) }, { inject: ['agents'] })) - await expect(creating).rejects.toThrow(/owner disposed during setup/) + await expect(creating).rejects.toThrow(/lifecycle disposed/) await owner.dispose() expect(announced.status).toBe('disposed') expect(statuses).toEqual(['disposed']) @@ -853,7 +714,7 @@ describe('agent scope lifecycle', () => { await retry.dispose() }) - it('rejects an exotic seed before publishing either reserved identity', async () => { + it('rejects an exotic durable seed before publishing either identity', async () => { const ctx = await harness() const published: string[] = [] ctx.on('session/created', () => { published.push('session') }) @@ -966,29 +827,6 @@ describe('agent scope lifecycle', () => { expect(heard).toEqual(['a1:2']) }) - it('a listener may drive the agent through its declared `this` (the carrier is method-transparent)', async () => { - // ds-review-bot regression: agent/* listeners are typed - // `this: Scoped`, and ReactLoopAgent's send/steer/cancel read the - // native-private #carrier — a proxy-receiver carrier made - // `this.send(...)` throw TypeError. The carrier binds methods to the real - // agent, so driving through the event `this` is a working supported shape. - const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - let followUpSent = false - ctx.on('agent/session-start', function (this: Agent) { - // Deliberately through `this`, not the args subject. - this.send(text('driven through this')) - followUpSent = true - }) - const second = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' }) - expect(followUpSent).toBe(true) - await second.whenIdle() - // The send actually reached the loop: the prompt ran a turn. - expect(second.session.events.some(e => e.type === 'turn/start')).toBe(true) - await agent.whenIdle() - }) - it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', async () => { const ctx = await harness() let handle!: Awaited> @@ -1044,18 +882,18 @@ describe('agent scope lifecycle', () => { await unload }) - it('successful handle disposal retires its caller ownership sentinel', async () => { + it('successful handle disposal retires its caller ownership effect', async () => { const ctx = await harness() - const agentId = AgentId('retired-owner-sentinel') + const agentId = AgentId('retired-owner-effect') const handle = await ctx.agents.create({ agentId, - sessionId: SessionId('retired-owner-sentinel-s'), + sessionId: SessionId('retired-owner-effect-s'), agentOptions: { model: 'mock' }, }) - expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.ownerLifecycle(${agentId})`) + expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.owner(${agentId})`) await handle.dispose() - expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.ownerLifecycle(${agentId})`)).toEqual([]) + expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([]) await ctx.fiber.dispose() }) @@ -1091,13 +929,13 @@ describe('agent scope lifecycle', () => { await ctx.fiber.dispose() }) - it('retains both identity reservations until scope teardown reaches quiescence', async () => { + it('reopens ids after detach while the prior private scope finishes quiescing', async () => { const ctx = await harness() const gate = Promise.withResolvers() const cleanupStarted = Promise.withResolvers() const sessionDisposed = Promise.withResolvers() - const agentId = AgentId('quiescent-reservation') - const sessionId = SessionId('quiescent-reservation-s') + const agentId = AgentId('quiescent-reuse') + const sessionId = SessionId('quiescent-reuse-s') ctx.on('session/disposed', (session) => { if (session.id === sessionId) sessionDisposed.resolve(undefined) }) @@ -1117,12 +955,12 @@ describe('agent scope lifecycle', () => { await Promise.all([sessionDisposed.promise, cleanupStarted.promise]) expect(ctx.agents.get(agentId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() - await expect(ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } })) - .rejects.toThrow(/reserved/) + const replacement = await ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } }) + expect(ctx.agents.get(agentId)).toBe(replacement.agent) + expect(ctx.sessions.get(sessionId)).toBe(replacement.agent.session) gate.resolve(undefined) await disposing - const replacement = await ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } }) await replacement.dispose() await ctx.fiber.dispose() }) diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts index c4ead42040..c275fee88c 100644 --- a/packages/core/agent-loop/tests/turn-stop.spec.ts +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -156,12 +156,9 @@ describe('agent/turn-stop', () => { expect(adapter.requests).toHaveLength(3) }) - it('fails throwing and malformed terminal policies closed while the driver survives', async () => { + it('fails a throwing terminal policy closed while the driver survives', async () => { const adapter = new MockAdapter([ textResponse('throwing policy'), - textResponse('malformed continue policy'), - textResponse('malformed false policy'), - textResponse('malformed null policy'), textResponse('healthy later turn'), ]) const ctx = await harness(adapter) @@ -179,21 +176,10 @@ describe('agent/turn-stop', () => { await send(agent, 'first') disposeThrowing() - for (const [index, malformed] of [ - { action: 'continue' }, - false, - null, - ].entries()) { - const disposeMalformed = agent.ctx.on('agent/turn-stop', () => malformed as unknown as ContinuationStop) - await send(agent, `malformed ${index}`) - disposeMalformed() - } - await send(agent, 'healthy') - expect(reasons.map(reason => reason.kind)).toEqual(['error', 'error', 'error', 'error', 'completed']) + expect(reasons.map(reason => reason.kind)).toEqual(['error', 'completed']) expect(errors).toContain('terminal policy exploded') - expect(errors).toContain("agent/turn-stop returned an invalid result; expected { action: 'stop' } or undefined") - expect(adapter.requests).toHaveLength(5) + expect(adapter.requests).toHaveLength(2) }) }) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 3663808847..290c5d90bf 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -8,20 +8,20 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i ### Public API -The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair deliberately reuses the stable carrier captured before entry commit and applies the same per-listener containment directly. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while registry/store-owned reservation capabilities keep both identities unpublished; creation awaits setup and a same-turn owner-unload checkpoint before either creation notification or the first assembly. Setup composes, it never drives or publishes: driving verbs and ordinary agent/session insertion both reject until the owning publication boundary. +The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. - `ctx.agents.register(agent: Agent): () => Promise | void` — record an **already-constructed** agent. Disposed with the calling fiber. -- Advanced ordered lifecycle: `reserve(id)` returns an opaque unpublished-identity capability whose `release` is the exact owner effect disposer, allowing the factory to place ID release after scope quiescence instead of racing owner unload as a sibling. `enter(agent, reservation?): () => void` claims the ID across runtime pinning and stable lifecycle-carrier construction, then inserts without announcing; a Proxy trap or filter getter cannot reentrantly overwrite the commit. `announce(agent)` reuses that carrier and emits `agent/created` exactly once for the exact live entry, rejecting repeat or reentrant announcement. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach is exact-object guarded, so a later listener cannot observe inverted lifecycle edges and a stale capability cannot delete a replacement. While reserved, bare `register`/`enter` calls for the id reject, including from setup. The factory uses this split; ordinary plugins use `register()`. +- Advanced ordered lifecycle: `enter(agent): () => void` performs the authoritative ID collision check and inserts without announcing; `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. - `ctx.agents.get(id: AgentId): Agent | undefined` - `ctx.agents.list(): Agent[]` #### Factory seam (creation) -Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target, captures and validates the factory's `createAgent` and `resume` callbacks once at registration, retains that target as their intentional receiver, and passes each call an explicit caller-bound `ownerCtx`; later method replacement cannot redirect a transaction, double tracing cannot break raw-identity state, and a plain non-Cordis factory receives enough context to implement caller ownership. +Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories. - `ctx.agents.setFactory(factory: AgentFactory): () => Promise | void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. -- `ctx.agents.create(options: CreateAgentOptions): Promise` — snapshot caller-owned IDs/options/metadata and hand the one-read raw seed synchronously to the session boundary for one-pass lossless-JSON materialization, construct and await optional setup while unpublished, insert both session and agent, then recheck caller and factory liveness before the first creation announcement and after each later notification boundary. Only a still-live transaction opens `agent/session-start` and starts a new loop on the caller-supplied `sessionId`. Registry/store reservation capabilities block every competing public insertion across setup; seed rejection, setup rejection, caller unload, factory unload, or cancellation from a creation listener publishes no drivable agent. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; any creation announcement that began is paired by `agent/disposed` or `session/disposed`. Rejects if no factory is registered. -- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — snapshot caller-owned IDs/options, load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh agent scope, await optional setup while unpublished, then follow the same insert both → pre-announcement liveness check → session announcement → liveness check → agent announcement → liveness check → session-start → final liveness check → loop-start boundary. The IDs are reserved across persistence load, setup, and teardown quiescence; load/setup rejection, caller unload, or factory unload leaves no drivable or live publication, while any creation edge that already began is paired during rollback. Rejects if no factory is registered or session persistence is unconfigured. +- `ctx.agents.create(options: CreateAgentOptions): Promise` — create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered. +- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured. `AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, `await`s its exit plus every outstanding idle-injection flush (not just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber. @@ -29,7 +29,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age `dsh-agent` declares the live `agent/*` coordination vocabulary so plugins do not depend on the concrete loop. Exact signatures, dispatch modes, scope-filtering rules, and payload contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the [architecture turn flow](../../../docs/architecture.md#turn-flow) shows their order relative to durable session events. -The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist, but concrete driving remains locked until the immediately following `agent/session-start`; that non-vetoing notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. +The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the owner-final exception: it runs after ordinary continuation and steering folding, and its terminal state remains through turn close and flush so steering from those later listeners cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#owner-final-policy-four-narrow-boundaries). diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index f8726c50cc..cbb641d271 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -6,8 +6,8 @@ * the first argument in one move, so a site cannot name a different subject. * The registry lifecycle pair is the deliberate exception: `enter()` captures * one stable carrier before commit and `announce()`/detach dispatch through it - * directly, preventing a mutable filter getter from changing or reentering the - * paired edges. The dev scoped-dispatch invariant checks both shapes. + * directly, so both lifecycle edges use the same routing identity. The dev + * scoped-dispatch invariant checks both shapes. * * @module @deepseek-ai/dsh-agent/dispatch */ @@ -61,16 +61,6 @@ export interface AgentEventDispatch { * @returns the serial chain's result (the first bail value, if any). */ serial(name: K, ...rest: Tail): Promise>> - /** - * Await listeners in order and return the first value other than `undefined`. - * Unlike Cordis `serial`, this does not silently treat `null` or `false` as - * abstentions. Use it for a runtime-validated public boundary whose declared - * abstention is exactly `undefined` (currently `agent/turn-stop`). - * @param name - the agent-subject event to dispatch. - * @param rest - the event's arguments after the injected agent. - * @returns the first non-undefined listener result, or undefined. - */ - strictSerial(name: K, ...rest: Tail): Promise>> /** * Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The * declared event parameters already end with the `next` callback, so `rest` @@ -110,10 +100,10 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { try { const returned: unknown = callback(...args) void Promise.resolve(returned).catch((error: unknown) => { - ctx.logger.warn(`agent event "${name}" listener rejected: ${renderThrown(error)}`) + ctx.logger.warn(`agent event "${name}" listener rejected: ${String(error)}`) }) } catch (error: unknown) { - ctx.logger.warn(`agent event "${name}" listener threw: ${renderThrown(error)}`) + ctx.logger.warn(`agent event "${name}" listener threw: ${String(error)}`) } } }, @@ -122,22 +112,6 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { const serial = ctx.serial as (thisArg: Scoped, name: string, ...args: unknown[]) => Promise return await serial(carrier, name, agent, ...rest) }, - strictSerial(name, ...rest) { - return (async (): Promise => { - // EventsService.dispatch applies the carrier filter and emits the same - // internal/dispatch instrumentation as ctx.serial, then mutates `args` - // down to the actual listener parameters. Invoke those callbacks in order - // ourselves so every non-undefined value reaches the caller's validator; - // Cordis serial would discard null/false before validation could see them. - const args: unknown[] = [carrier, name, agent, ...rest] - const callbacks = ctx.events.dispatch('serial', args) - for (const callback of callbacks) { - const result: unknown = await callback(...args) - if (result !== undefined) return result - } - return undefined - })() as Promise>> - }, waterfall(name, ...rest) { // eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function const waterfall = ctx.waterfall as (thisArg: Scoped, name: string, ...args: unknown[]) => never @@ -146,15 +120,6 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { } } -/** 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 '' - } -} - /** * The assembly context for one agent's prompt: the typed `agent` DX field and * the `scope` layer selector, set together (setting `agent` without `scope` diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 5526006796..07ec5adb99 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -40,21 +40,20 @@ declare module 'cordis' { */ export interface CreateAgentOptions { /** The agent's id (the registry handle). */ - agentId: AgentId + readonly agentId: AgentId /** The live session's id (NOT derived from agentId). */ - sessionId: SessionId + readonly sessionId: SessionId /** * Session creation metadata: validated absolute `cwd`, `parentSession` * fork lineage, and the `seedLength` seed boundary. Mirrors the * `cwd`/`parentSession`/`seedLength` fields of * {@link CreateSessionOptions.meta} in dsh-session (the internal-only * `createdAt`, used when reconstructing a persisted session, is deliberately - * excluded — a factory caller never sets it). The factory reads this raw - * reference once and hands it synchronously to the session boundary, which - * rejects an exotic shell and captures each accepted field once before any - * asynchronous setup. + * excluded — a factory caller never sets it). This is durable session data, + * so the session boundary validates and snapshots it before asynchronous + * setup begins. */ - meta?: { cwd?: string; parentSession?: SessionId; seedLength?: number } + readonly meta?: { readonly cwd?: string; readonly parentSession?: SessionId; readonly seedLength?: number } /** * Seed events to reconstruct the child session's log from (the fork lineage * primitive). When present, the factory creates the session with this event @@ -64,12 +63,14 @@ export interface CreateAgentOptions { * from seq 0, carry only lossless-JSON data, and be balanced (no open * turn/step, no dangling tool-call), or the session constructor (and the * dev-mode invariants replay) reject it. The factory passes the raw seed to - * the synchronous one-pass validator/copier; it never pre-clones and thereby - * sanitizes exotic prototypes. Absent for a fresh (spawn) child. + * the session's durable validator/snapshot boundary. Absent for a fresh + * (spawn) child. */ - seed?: SessionEvent[] + readonly seed?: readonly SessionEvent[] /** Per-agent options (model, …). */ - agentOptions?: AgentOptions + readonly agentOptions?: AgentOptions + /** Optional creation-only cancellation signal; detached before the returned handle becomes visible. */ + readonly signal?: AbortSignal /** * Creation-time composition of the agent's scoped world. The factory awaits * setup after minting `agentCtx` but BEFORE inserting or announcing either @@ -80,11 +81,11 @@ export interface CreateAgentOptions { * first prompt assembly. A throw/rejection or owner disposal rolls the scope * back without publishing either id. * - * **Setup composes, it never drives**: calling `send`/`steer`/`inject` here - * would run an unpublished agent and violate the session-start boundary. - * Drive the agent only after the creation promise resolves. + * **Setup composes, it never drives**: the callback is trusted same-process + * code and receives the full scoped context, so this is a contract rather + * than a runtime restriction. Drive the agent only after creation resolves. */ - setup?: (agentCtx: Context) => Promise | void + readonly setup?: (agentCtx: Context) => Promise | void } /** @@ -93,21 +94,23 @@ export interface CreateAgentOptions { */ export interface ResumeAgentOptions { /** The agent's id (the registry handle). */ - agentId: AgentId + readonly agentId: AgentId /** The persisted session id to load and resume on. */ - resumeSessionId: SessionId + readonly resumeSessionId: SessionId /** Per-agent options (model, …). */ - agentOptions?: AgentOptions + readonly agentOptions?: AgentOptions + /** Optional creation-only cancellation signal for persistence load/setup; detached before return. */ + readonly signal?: AbortSignal /** * Resume-time composition of the agent's fresh scoped world. Persistence is * loaded first; the factory then mints `agentCtx` and awaits setup while the * reconstructed session and agent remain unpublished. The callback has the - * same composition-only contract as {@link CreateAgentOptions.setup}: all - * registrations exist before either creation announcement, driving verbs are - * unavailable until the session-start boundary, and rejection or owner - * disposal rolls the transaction back without publishing either id. + * same trusted composition-only contract as + * {@link CreateAgentOptions.setup}: all registrations exist before either + * creation announcement, and rejection or owner disposal rolls the + * transaction back without publishing either id. */ - setup?: (agentCtx: Context) => Promise | void + readonly setup?: (agentCtx: Context) => Promise | void } /** @@ -143,8 +146,8 @@ export interface AgentFactory { /** * Create a new agent on a caller-supplied session id. Async because creation * awaits unpublished setup, inserts both session and agent, emits their - * creation notifications in order, unlocks driving at - * `agent/session-start`, and only then starts the loop. The sequence is + * creation notifications in order, emits `agent/session-start`, and only + * then starts the loop. The sequence is * rollback-covered, but notifications delivered before a later listener * failure remain observable; every agent or session creation announcement * that began is paired by `agent/disposed` or `session/disposed` during @@ -163,8 +166,8 @@ export interface AgentFactory { * Load a persisted session and resume an agent on it. Async because it awaits * both `ctx.sessionPersistence.load` and the optional unpublished setup * transaction; must be called after that service exists (consumers inject - * `sessionPersistence`). Publication and drive unlocking follow the same - * ordered boundary as {@link createAgent}. + * `sessionPersistence`). Publication follows the same ordered boundary as + * {@link createAgent}. * @param ownerCtx - caller-bound context that owns load, setup, and the live handle. * @param options - persisted identity, configuration, and optional setup. * @returns the owned handle after setup, both announcements, and loop start complete. @@ -172,72 +175,22 @@ export interface AgentFactory { resume(ownerCtx: Context, options: ResumeAgentOptions): Promise } -/** One accepted factory target plus the callback identities captured at registration. */ -interface AcceptedAgentFactory { - target: AgentFactory - createAgent: AgentFactory['createAgent'] - resume: AgentFactory['resume'] -} - -/** Slot reservation while callback accessors are being captured. */ -const ACCEPTING_FACTORY = Symbol('accepting agent factory') - -/** Capture and validate the complete factory contract exactly once. */ -function acceptAgentFactory(factory: unknown): AcceptedAgentFactory { - if ((typeof factory !== 'object' && typeof factory !== 'function') || factory === null) { - throw new TypeError('agent factory must be a non-null object or function') - } - // A service read through ctx is already a Cordis trace proxy. Retaining that - // proxy and tracing it again for each create() caller produces two shadow - // layers; raw-identity state (AgentLoop's private ownership controller is - // one example) then unwraps only to the inner proxy instead of its service. - // Canonicalize the one framework-produced layer at acceptance and capture - // callbacks from the concrete target. Plain objects expose no original. - const original: unknown = Reflect.get(factory, symbols.original) - const target = ((typeof original === 'object' || typeof original === 'function') && original !== null) - ? original - : factory - const createAgent: unknown = Reflect.get(target, 'createAgent') - const resume: unknown = Reflect.get(target, 'resume') - if (typeof createAgent !== 'function') throw new TypeError('agent factory createAgent must be a function') - if (typeof resume !== 'function') throw new TypeError('agent factory resume must be a function') - return Object.freeze({ - target: target as AgentFactory, - createAgent: createAgent as AgentFactory['createAgent'], - resume: resume as AgentFactory['resume'], - }) -} - /** Thrown when create/resume is called before an agent factory is registered. */ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plugin)' -/** 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 '' - } +/** All mutable lifecycle state for one exact registry entry. */ +interface AgentEntry { + readonly id: AgentId + readonly agent: Agent + readonly carrier: Scoped + announced: boolean + announcing: boolean + detachRequested: boolean } -/** - * Unforgeable ownership handle for one unpublished agent id. The factory holds - * this object across asynchronous setup; while it is live, ordinary public - * registration of that id fails, so setup cannot publish the factory's agent - * (or a replacement with the same id) ahead of the transaction. Callers obtain - * handles only from {@link AgentRegistry.reserve}. - */ -export interface AgentRegistrationReservation { - /** The reserved registry id. */ - readonly id: AgentId - /** - * Release the unpublished reservation; idempotent. The registry 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 +/** Plain holder prevents Cordis from tracing the factory field before the caller context is known. */ +interface FactorySlot { + readonly target: AgentFactory } /** @@ -248,22 +201,9 @@ export interface AgentRegistrationReservation { * {@link setFactory}. */ export class AgentRegistry extends Service { - private store = new Map() - /** Ids claimed across caller-code boundaries before their exact entry commits. */ - private enteringIds = new Set() - /** The one accepted registry key for each live agent; never reread caller state. */ - private acceptedIds = new WeakMap() - /** Unpublished identities held across factory setup/load transactions. */ - private reservations = new Map() - /** Entries whose `agent/created` announcement phase began. */ - private announced = new WeakSet() - /** Entries currently dispatching `agent/created`; detach waits for that dispatch to unwind. */ - private announcing = new WeakSet() - /** A detach requested reentrantly from `agent/created`. */ - private pendingDetach = new WeakSet() - /** Stable lifecycle dispatch carrier captured before an entry commits. */ - private carriers = new WeakMap>() - private factory: AcceptedAgentFactory | typeof ACCEPTING_FACTORY | undefined + private store = new Map() + private entries = new WeakMap() + private factory: FactorySlot | undefined constructor(ctx: Context) { super(ctx, 'agents') @@ -276,41 +216,13 @@ export class AgentRegistry extends Service { ctx.accessor('agent', { get: () => undefined }) } - /** - * Reserve an unpublished agent id. Registration through {@link register} or - * bare {@link enter} fails until the returned capability is released; the - * owning factory passes the exact capability back to `enter` at publication. - * This makes “setup cannot publish” structural rather than a cooperative - * convention, including attempts to register a different object under the - * reserved id. The reservation belongs to the calling fiber and is released - * automatically if that owner unloads before the transaction settles. - * @param id - the id the factory transaction will publish. - * @returns the opaque reservation capability. - * @throws if the id is malformed, live, or already reserved. - */ - reserve(id: AgentId): AgentRegistrationReservation { - if (typeof id !== 'string') throw new TypeError('agent id must be a string') - if (this.store.has(id) || this.reservations.has(id) || this.enteringIds.has(id)) { - throw new Error(`agent "${id}" is already registered or reserved`) - } - const rawRelease = (): void => { - this.reservations.delete(id) - } - // `release` is the exact effect disposer. A composite lifecycle can yield - // it by identity, moving automatic owner cleanup from a racing sibling to - // the transaction's final ordered position. - const release = this.ctx.effect(() => rawRelease, `agents.reserve(${id})`) - const reservation: AgentRegistrationReservation = Object.freeze({ id, release }) - this.reservations.set(id, reservation) - return reservation - } - /** * Register the agent-creation factory (the loop calls this on construction, - * effect-scoped). The registry captures both callback identities once and - * later invokes them against the retained target receiver. Throws if a - * factory is already registered. Returns the disposer; on dispose the - * factory slot is cleared. + * effect-scoped). A traced Cordis service is canonicalized to its concrete + * target; each create/resume call is then traced through that caller's + * context so ownership follows the caller without stacking proxy layers. + * Throws if a factory is already registered. Returns the disposer; on + * dispose the factory slot is cleared. * @param factory - the loop-owned factory {@link create}/{@link resume} delegate to. * @returns the disposer that clears the factory slot. The exact * Cordis effect disposer (single-shot): composite (generator) effects may @@ -319,17 +231,11 @@ export class AgentRegistry extends Service { setFactory(factory: AgentFactory): () => Promise | void { const dispose = this.ctx.effect(() => { if (this.factory !== undefined) throw new Error('an agent factory is already registered') - // Claim the slot before reading caller-controlled method accessors. A - // getter may synchronously re-enter setFactory(); it must observe the - // registration in progress instead of installing a nested factory that - // the outer call would silently overwrite. - this.factory = ACCEPTING_FACTORY - try { - this.factory = acceptAgentFactory(factory) - } catch (error: unknown) { - this.factory = undefined - throw error - } + // Avoid stacking two Cordis shadow layers when a caller passes a Service + // already read through a context. Calls are re-traced through their + // actual owner context below. + const target = (factory as AgentFactory & { [symbols.original]?: AgentFactory })[symbols.original] ?? factory + this.factory = { target } return () => { this.factory = undefined } }, 'agents.setFactory()') // The exact cordis effect disposer (the agents.register() convention): a @@ -339,11 +245,10 @@ export class AgentRegistry extends Service { return dispose } - /** Return the accepted factory, excluding absence and reentrant acceptance. */ - private requireFactory(): AcceptedAgentFactory { - const accepted = this.factory - if (accepted === undefined || accepted === ACCEPTING_FACTORY) throw new Error(NO_FACTORY_MESSAGE) - return accepted + /** Return the active creation factory. */ + private requireFactory(): FactorySlot { + if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE) + return this.factory } /** @@ -356,14 +261,15 @@ export class AgentRegistry extends Service { * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async create(options: CreateAgentOptions): Promise { - const accepted = this.requireFactory() const ownerCtx = this.ctx // Re-trace a Service-backed factory through the accessing context // explicitly. This preserves AgentLoop's dependency origin while binding // its effects to ownerCtx; plain factories receive ownerCtx as an explicit // capability and need no Cordis tracker magic. - const receiver = getTraceable(ownerCtx, accepted.target) - return Reflect.apply(accepted.createAgent, receiver, [ownerCtx, options]) + const { target } = this.requireFactory() + const receiver = getTraceable(ownerCtx, target) + // eslint-disable-next-line @typescript-eslint/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver + return Reflect.apply(target.createAgent, receiver, [ownerCtx, options]) } /** @@ -374,10 +280,11 @@ export class AgentRegistry extends Service { * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async resume(options: ResumeAgentOptions): Promise { - const accepted = this.requireFactory() const ownerCtx = this.ctx - const receiver = getTraceable(ownerCtx, accepted.target) - return Reflect.apply(accepted.resume, receiver, [ownerCtx, options]) + const { target } = this.requireFactory() + const receiver = getTraceable(ownerCtx, target) + // eslint-disable-next-line @typescript-eslint/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver + return Reflect.apply(target.resume, receiver, [ownerCtx, options]) } /** @@ -413,74 +320,27 @@ export class AgentRegistry extends Service { * returned detach closure into its pre-installed composite teardown before * calling {@link announce}. Ordinary callers use {@link register}. * @param agent - the prepared, unpublished agent. - * @param reservation - the exact unpublished-id capability, when a factory - * reserved this id across setup. * @returns an idempotent closure that removes this exact entry and emits * `agent/disposed` with listener failures contained. When called from a * synchronous `agent/created` listener, removal and disposal wait until * that creation dispatch unwinds. */ - enter(agent: Agent, reservation?: AgentRegistrationReservation): () => void { + enter(agent: Agent): () => void { const id = agent.id - if (typeof id !== 'string') throw new TypeError('agent id must be a string') - const held = this.reservations.get(id) - if (reservation === undefined) { - if (held !== undefined) throw new Error(`agent "${id}" is reserved for unpublished creation`) - } else if (reservation.id !== id || held !== reservation) { - throw new Error(`agent "${id}" registration reservation is not active for this id`) + const carrier = scopeTarget(agent, agent) + // This is the authoritative collision boundary. Concurrent create/resume + // operations may both prepare, but only one exact entry can publish. + if (this.entries.has(agent) || this.store.has(id)) throw new Error(`agent "${id}" is already registered`) + const entry: AgentEntry = { + id, + agent, + carrier, + announced: false, + announcing: false, + detachRequested: false, } - if (this.acceptedIds.has(agent)) { - throw new Error(`agent "${id}" is already registered`) - } - if (this.store.has(id) || this.enteringIds.has(id)) { - throw new Error(`agent "${id}" is already registered`) - } - this.enteringIds.add(id) - let carrier: Scoped - try { - // Registration accepts ownership of the public identity contract. Pin an - // own data slot from the one captured value so a custom JavaScript Agent - // with a getter or writable field cannot later present a different id to - // event listeners while the registry still owns the accepted key. - try { - Object.defineProperty(agent, 'id', { - value: id, - enumerable: true, - writable: false, - configurable: false, - }) - } catch { - // Only the engine's property-definition failure is normalized; filter - // construction below retains its own precise failure. - throw new TypeError('agent id must be installable as a stable own property') - } - // Capture one carrier for the paired lifecycle edges. Constructing it - // reads a custom Agent's Context.filter and is therefore caller code; - // the id claim above makes a same-id reentrant enter lose deterministically. - carrier = scopeTarget(agent, agent) - } finally { - // Kept through the entire caller-code window; the final commit below is - // synchronous and callback-free. - this.enteringIds.delete(id) - } - const currentReservation = this.reservations.get(id) - if (reservation === undefined) { - /* v8 ignore next 2 -- reserve() rejects enteringIds, so no callback in - * carrier construction can install a new same-id reservation */ - if (currentReservation !== undefined) { - throw new Error(`agent "${id}" is reserved for unpublished creation`) - } - } else if (currentReservation !== reservation) { - throw new Error(`agent "${id}" registration reservation is not active for this id`) - } - /* v8 ignore next 2 -- the enteringIds claim blocks every public same-id - * commit until this callback-free final check has completed */ - if (this.acceptedIds.has(agent) || this.store.has(id)) { - throw new Error(`agent "${id}" is already registered`) - } - this.store.set(id, agent) - this.acceptedIds.set(agent, id) - this.carriers.set(agent, carrier) + this.store.set(id, entry) + this.entries.set(agent, entry) let entered = true const detach = (): void => { if (!entered) return @@ -490,49 +350,42 @@ export class AgentRegistry extends Service { // the advanced detach capability, so make that ordering structural: // visibility and the paired disposal are deferred until announce()'s // synchronous dispatch has unwound. - if (this.announcing.has(agent)) { - this.pendingDetach.add(agent) + if (entry.announcing) { + entry.detachRequested = true return } - this.detachEntered(agent, id) + this.detachEntered(entry) } return detach } /** Remove one exact entered agent and emit its paired disposal when announced. */ - private detachEntered(agent: Agent, id: AgentId): void { - this.pendingDetach.delete(agent) + private detachEntered(entry: AgentEntry): void { + entry.detachRequested = false // A stale capability can never delete a later same-id lifecycle. The - // commit claim prevents this mismatch in normal operation; retain the - // exact-object guard as the final identity boundary. - /* 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) !== agent || this.acceptedIds.get(agent) !== id) return - this.store.delete(id) - this.acceptedIds.delete(agent) - const carrier = this.carriers.get(agent) - this.carriers.delete(agent) + // captured entry identity is the final boundary. + if (this.store.get(entry.id) !== entry) return + this.store.delete(entry.id) + this.entries.delete(entry.agent) // An insertion rolled back before announce was never externally created, // so emitting disposed would invent an impossible lifecycle edge. Marking // happens before the created emit: if a later created listener throws, // earlier listeners may already have observed it and must see disposal. - if (!this.announced.delete(agent)) return - /* v8 ignore next -- enter commits the carrier with the exact store entry */ - if (carrier === undefined) throw new Error(`agent "${id}" has no dispatch carrier`) - this.emitDisposed(agent, carrier, id) + if (!entry.announced) return + this.emitDisposed(entry) } /** Emit the paired disposal edge through the entry's stable carrier. */ - private emitDisposed(agent: Agent, carrier: Scoped, id: AgentId): void { - const args: unknown[] = [carrier, 'agent/disposed', agent] + private emitDisposed(entry: AgentEntry): void { + const args: unknown[] = [entry.carrier, 'agent/disposed', entry.agent] 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(`agent "${id}": agent/disposed listener rejected: ${renderThrown(error)}`) + this.ctx.logger.warn(`agent "${entry.id}": agent/disposed listener rejected: ${String(error)}`) }) } catch (error: unknown) { - this.ctx.logger.warn(`agent "${id}": agent/disposed listener threw: ${renderThrown(error)}`) + this.ctx.logger.warn(`agent "${entry.id}": agent/disposed listener threw: ${String(error)}`) } } } @@ -545,21 +398,18 @@ export class AgentRegistry extends Service { * creation listener). */ announce(agent: Agent): void { - const id = this.acceptedIds.get(agent) - if (id === undefined || this.store.get(id) !== agent) { - throw new Error(`agent "${id ?? ''}" is not live in this registry`) + const entry = this.entries.get(agent) + if (entry === undefined || this.store.get(entry.id) !== entry) { + throw new Error(`agent "${agent.id}" is not live in this registry`) } - if (this.announced.has(agent) || this.announcing.has(agent)) { - throw new Error(`agent "${id}" was already announced`) + if (entry.announced || entry.announcing) { + throw new Error(`agent "${entry.id}" was already announced`) } - const carrier = this.carriers.get(agent) - /* v8 ignore next -- enter commits the carrier with the exact store entry */ - if (carrier === undefined) throw new Error(`agent "${id}" has no dispatch carrier`) // Mark before dispatch so a listener cannot recursively create a second // lifecycle edge; detach still pairs a partially delivered first edge. - this.announcing.add(agent) - this.announced.add(agent) - const args: unknown[] = [carrier, 'agent/created', agent] + entry.announcing = true + entry.announced = true + const args: unknown[] = [entry.carrier, 'agent/created', entry.agent] try { for (const callback of this.ctx.events.dispatch('emit', args)) { // A synchronous creation failure vetoes publication and rolls back. @@ -567,12 +417,12 @@ export class AgentRegistry extends Service { // observe and report it instead of leaking an unhandled rejection. const returned: unknown = callback(...args) void Promise.resolve(returned).catch((error: unknown) => { - this.ctx.logger.warn(`agent "${id}": agent/created listener rejected: ${renderThrown(error)}`) + this.ctx.logger.warn(`agent "${entry.id}": agent/created listener rejected: ${String(error)}`) }) } } finally { - this.announcing.delete(agent) - if (this.pendingDetach.has(agent)) this.detachEntered(agent, id) + entry.announcing = false + if (entry.detachRequested) this.detachEntered(entry) } } @@ -582,7 +432,7 @@ export class AgentRegistry extends Service { * @returns the agent, or undefined when no live agent has that id. */ get(id: AgentId): Agent | undefined { - return this.store.get(id) + return this.store.get(id)?.agent } /** @@ -590,7 +440,7 @@ export class AgentRegistry extends Service { * @returns a fresh array; mutating it does not affect the registry. */ list(): Agent[] { - return [...this.store.values()] + return [...this.store.values()].map(entry => entry.agent) } } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 419cc9d1fe..9350a5cc03 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -294,10 +294,10 @@ declare module 'cordis' { // ---- lifecycle (emit) ---- /** * An agent's fully composed scoped world was published in the - * {@link AgentRegistry}. Its session is already live in the session store, - * but concrete factories may keep driving verbs locked until the subsequent - * `agent/session-start` boundary; that event is the first supported place - * to inject or queue work during startup. A synchronous listener throw + * {@link AgentRegistry}. Its session is already live in the session store. + * Setup is composition-only by contract; the subsequent + * `agent/session-start` boundary is the first supported place to inject or + * queue startup work. A synchronous listener throw * vetoes publication and rollback emits the matching disposal edges; * returned-promise rejection is observed and logged but cannot * retroactively veto this synchronous boundary. A synchronous listener @@ -346,9 +346,7 @@ declare module 'cordis' { /** * A message entered the agent's inbox (queued or steering). Content and the * resolved source are the detached, deeply-frozen values retained by the - * inbox; the `info` wrapper is frozen too, so one listener cannot rewrite - * what another listener observes. `source` has defaults applied and is not - * the caller's raw options. + * inbox. `source` has defaults applied and is not the caller's raw options. * @param agent - the agent whose inbox received the message. * @param content - the accepted content blocks retained by the inbox. * @param info - the accepted source plus whether it entered as steering. diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index ab3202a297..cd40ba59e3 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { Context, Service, symbols } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Agent, AgentId, agentEvents } from '@deepseek-ai/dsh-agent' -import type { AgentFactory, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentId, agentEvents } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentFactory, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' function stubAgent(rawId: string): Agent { const id = AgentId(rawId) @@ -11,8 +11,6 @@ function stubAgent(rawId: string): Agent { options: {}, session: new Session(SessionId(`${id}-session`)), status: 'idle', - // A bare context stands in for the agent scope: registry tests never - // register through it, they only need the field present. ctx: new Context(), send() {}, steer() {}, @@ -23,382 +21,126 @@ function stubAgent(rawId: string): Agent { } describe('AgentRegistry', () => { - it('registers agents and emits created/disposed events', async () => { + it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - - const created: string[] = [] - const disposed: string[] = [] - ctx.on('agent/created', agent => void created.push(agent.id)) - ctx.on('agent/disposed', agent => void disposed.push(agent.id)) + const lifecycle: string[] = [] + ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`)) + ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) const agent = stubAgent('a1') const dispose = ctx.agents.register(agent) - expect(created).toEqual(['a1']) - expect(ctx.agents.get(AgentId('a1'))).toBe(agent) + expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.agents.list()).toEqual([agent]) + expect(() => ctx.agents.register(stubAgent('a1'))).toThrow(/already registered/) await dispose() - expect(disposed).toEqual(['a1']) - expect(ctx.agents.get(AgentId('a1'))).toBeUndefined() + expect(ctx.agents.get(agent.id)).toBeUndefined() + expect(lifecycle).toEqual(['created:a1', 'disposed:a1']) }) - it('rejects duplicate ids and unregisters on fiber dispose (HMR safety)', async () => { + it('rolls an entry back and pairs a partially delivered creation when a listener throws', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - ctx.agents.register(stubAgent('main')) - expect(() => ctx.agents.register(stubAgent('main'))).toThrow('already registered') + const lifecycle: string[] = [] + ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`)) + ctx.on('agent/created', () => { throw new Error('creation veto') }) + ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.agents.register(stubAgent('scoped')) - }, { inject: ['agents'] })) - expect(ctx.agents.list().map(a => a.id)).toEqual(['main', 'scoped']) - - await fiber.dispose() - expect(ctx.agents.list().map(a => a.id)).toEqual(['main']) + expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto') + expect(ctx.agents.get(AgentId('vetoed'))).toBeUndefined() + expect(lifecycle).toEqual(['created:vetoed', 'disposed:vetoed']) }) - it('rolls back the agent entry when an agent/created listener throws (P1-1)', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - - let threw = false - ctx.on('agent/created', () => { - if (!threw) { threw = true; throw new Error('boom created listener') } - }) - - // The throwing emit must roll the entry back, not leak it. - expect(() => ctx.agents.register(stubAgent('main'))).toThrow('boom created listener') - expect(ctx.agents.get(AgentId('main'))).toBeUndefined() // rolled back, not leaked - - // A subsequent listener-free register of the SAME id succeeds and is - // tracked exactly once (the duplicate-id check is not wedged). - const dispose = ctx.agents.register(stubAgent('main')) - expect(ctx.agents.list().map(a => a.id)).toEqual(['main']) - await dispose() - expect(ctx.agents.get(AgentId('main'))).toBeUndefined() - }) - - it('observes async agent/created rejection without rolling back or starving peers', async () => { + it('contains asynchronous creation rejection and every disposal-listener failure', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) 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 heard: string[] = [] - ctx.on('agent/created', () => Promise.reject(new Error('ordinary async failure')) as never) - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- hostile thrown values are the boundary under test - ctx.on('agent/created', () => Promise.reject(hostile) as never) - ctx.on('agent/created', (agent) => { heard.push(agent.id) }) + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + ctx.on('agent/created', () => Promise.reject(new Error('created async')) as never) + ctx.on('agent/disposed', () => { throw new Error('disposed sync') }) + ctx.on('agent/disposed', () => Promise.reject(new Error('disposed async')) as never) + ctx.on('agent/disposed', agent => void heard.push(agent.id)) - const agent = stubAgent('async-created') - const dispose = ctx.agents.register(agent) + const dispose = ctx.agents.register(stubAgent('contained')) await Promise.resolve() - await Promise.resolve() - - expect(ctx.agents.get(agent.id)).toBe(agent) - expect(heard).toEqual(['async-created']) - expect(warnings).toEqual([ - 'agent "async-created": agent/created listener rejected: Error: ordinary async failure', - 'agent "async-created": agent/created listener rejected: ', - ]) await dispose() + await Promise.resolve() + + expect(heard).toEqual(['contained']) + expect(warnings).toEqual([ + 'agent "contained": agent/created listener rejected: Error: created async', + 'agent "contained": agent/disposed listener threw: Error: disposed sync', + 'agent "contained": agent/disposed listener rejected: Error: disposed async', + ]) }) - it('splits insertion from announcement and makes the detach exact/idempotent', async () => { + it('separates entry from announcement and stale/idempotent detach cannot remove a replacement', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - const created: Agent[] = [] - const disposed: Agent[] = [] - ctx.on('agent/created', agent => void created.push(agent)) - ctx.on('agent/disposed', agent => void disposed.push(agent)) + const lifecycle: string[] = [] + ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`)) + ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) const first = stubAgent('split') const detachFirst = ctx.agents.enter(first) - expect(ctx.agents.get(first.id)).toBe(first) - expect(created).toEqual([]) + expect(lifecycle).toEqual([]) ctx.agents.announce(first) - expect(created).toEqual([first]) + expect(() => { ctx.agents.announce(first) }).toThrow(/already announced/) detachFirst() detachFirst() - expect(disposed).toEqual([first]) const replacement = stubAgent('split') const detachReplacement = ctx.agents.enter(replacement) - // A stale repeated detach cannot remove the replacement. detachFirst() expect(ctx.agents.get(replacement.id)).toBe(replacement) expect(() => { ctx.agents.announce(first) }).toThrow(/not live/) detachReplacement() - // The replacement was inserted but never announced, so rollback produces - // no disposed-without-created notification. - expect(disposed).toEqual([first]) + expect(lifecycle).toEqual(['created:split', 'disposed:split']) }) - it('captures and pins one runtime id before insertion, announcement, and detach', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - const existing = stubAgent('occupied') - const disposeExisting = ctx.agents.register(existing) - const candidate = stubAgent('placeholder') - let reads = 0 - Object.defineProperty(candidate, 'id', { - configurable: true, - get() { - reads += 1 - return reads === 1 ? AgentId('accepted') : AgentId('occupied') - }, - }) - - const detach = ctx.agents.enter(candidate) - expect(reads).toBe(1) - expect(candidate.id).toBe('accepted') - expect(reads).toBe(1) - expect(Object.getOwnPropertyDescriptor(candidate, 'id')).toMatchObject({ - configurable: false, - writable: false, - value: 'accepted', - }) - expect(ctx.agents.get(AgentId('accepted'))).toBe(candidate) - expect(ctx.agents.get(AgentId('occupied'))).toBe(existing) - expect(() => ctx.agents.enter(candidate)).toThrow(/already registered/) - - ctx.agents.announce(candidate) - detach() - expect(ctx.agents.get(AgentId('accepted'))).toBeUndefined() - expect(ctx.agents.get(AgentId('occupied'))).toBe(existing) - await disposeExisting() - - expect(() => ctx.agents.enter({ ...stubAgent('bad'), id: 42 } as unknown as Agent)) - .toThrow(/id must be a string/) - const pinnedAccessor = stubAgent('pinned') - Object.defineProperty(pinnedAccessor, 'id', { - configurable: false, - get: () => AgentId('pinned'), - }) - expect(() => ctx.agents.enter(pinnedAccessor)).toThrow(/installable as a stable own property/) - }) - - it('claims an id across a Proxy defineProperty trap before committing the exact entry', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - const id = AgentId('reentrant-enter') - const nested = stubAgent(id) - let nestedError = '' - let attempted = false - const target = stubAgent(id) - const outer = new Proxy(target, { - defineProperty(inner, property, descriptor) { - if (property === 'id' && !attempted) { - attempted = true - try { - ctx.agents.enter(nested) - } catch (error: unknown) { - nestedError = String(error) - } - } - return Reflect.defineProperty(inner, property, descriptor) - }, - }) - - const detach = ctx.agents.enter(outer) - expect(nestedError).toMatch(/already registered/) - expect(ctx.agents.get(id)).toBe(outer) - detach() - expect(ctx.agents.get(id)).toBeUndefined() - }) - - it('captures one lifecycle carrier before commit so a filter getter cannot invert edges', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - const events: string[] = [] - const agent = stubAgent('reentrant-carrier') - let detach = (): void => {} - Object.defineProperty(agent, Context.filter, { - configurable: true, - get() { - events.push('filter-getter') - detach() - return undefined - }, - }) - detach = ctx.agents.enter(agent) - ctx.on('agent/created', () => { events.push('created') }) - ctx.on('agent/disposed', () => { events.push('disposed') }) - - ctx.agents.announce(agent) - expect(events).toEqual(['filter-getter', 'created']) - expect(ctx.agents.get(agent.id)).toBe(agent) - detach() - expect(events).toEqual(['filter-getter', 'created', 'disposed']) - expect(ctx.agents.get(agent.id)).toBeUndefined() - }) - - it('revalidates an exact reservation after carrier construction runs caller code', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - const reservation = ctx.agents.reserve(AgentId('released-during-enter')) - const agent = stubAgent('released-during-enter') - Object.defineProperty(agent, Context.filter, { - configurable: true, - get() { - reservation.release() - return undefined - }, - }) - - expect(() => ctx.agents.enter(agent, reservation)).toThrow(/reservation is not active/) - expect(ctx.agents.get(agent.id)).toBeUndefined() - }) - - it('observes an async agent/disposed rejection through the stable carrier', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - const warnings: string[] = [] - ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn - ctx.on('agent/disposed', () => Promise.reject(new Error('late disposal failure')) as never) - const agent = stubAgent('async-disposed') - const detach = ctx.agents.enter(agent) - ctx.agents.announce(agent) - - detach() - await Promise.resolve() - await Promise.resolve() - expect(warnings).toEqual([ - 'agent "async-disposed": agent/disposed listener rejected: Error: late disposal failure', - ]) - }) - - it('uses an opaque one-id reservation to gate unpublished factory insertion', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - const held = ctx.agents.reserve(AgentId('held')) - - expect(() => ctx.agents.reserve(AgentId('held'))).toThrow(/already registered or reserved/) - expect(() => ctx.agents.enter(stubAgent('held'))).toThrow(/reserved for unpublished creation/) - const other = ctx.agents.reserve(AgentId('other')) - expect(() => ctx.agents.enter(stubAgent('held'), other)).toThrow(/not active for this id/) - - const agent = stubAgent('held') - const detach = ctx.agents.enter(agent, held) - ctx.agents.announce(agent) - held.release() - held.release() - expect(ctx.agents.get(AgentId('held'))).toBe(agent) - expect(() => ctx.agents.reserve(AgentId('held'))).toThrow(/already registered or reserved/) - detach() - other.release() - - const expired = ctx.agents.reserve(AgentId('expired')) - expired.release() - expect(() => ctx.agents.enter(stubAgent('expired'), expired)).toThrow(/not active for this id/) - expect(() => ctx.agents.reserve(42 as unknown as AgentId)).toThrow(/id must be a string/) - }) - - it('owns reservations by the calling fiber and rolls back failed ownership registration', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - let held!: import('@deepseek-ai/dsh-agent').AgentRegistrationReservation - let scopedAgents!: AgentRegistry - const owner = await ctx.plugin(Object.assign((inner: Context) => { - scopedAgents = inner.agents - held = inner.agents.reserve(AgentId('fiber-held')) - }, { inject: ['agents'] })) - - expect(() => ctx.agents.reserve(AgentId('fiber-held'))).toThrow(/already registered or reserved/) - await owner.dispose() - const reused = ctx.agents.reserve(AgentId('fiber-held')) - reused.release() - held.release() // idempotent after the automatic owner-disposal release - - // A disposed tracker cannot own a new effect. The failed effect install - // must remove the map entry it tentatively reserved before propagating. - expect(() => scopedAgents.reserve(AgentId('inactive-owner'))).toThrow(/inactive context/) - const recovered = ctx.agents.reserve(AgentId('inactive-owner')) - recovered.release() - }) - - it('rejects direct and reentrant repeat announcements to preserve one lifecycle pair', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - let created = 0 - let disposed = 0 - let reentrantError = '' - ctx.on('agent/created', (agent) => { - created += 1 - try { - ctx.agents.announce(agent) - } catch (error: unknown) { - reentrantError = String(error) - } - }) - ctx.on('agent/disposed', () => { disposed += 1 }) - - const agent = stubAgent('once') - const detach = ctx.agents.enter(agent) - ctx.agents.announce(agent) - expect(reentrantError).toMatch(/already announced/) - expect(() => { ctx.agents.announce(agent) }).toThrow(/already announced/) - detach() - expect({ created, disposed }).toEqual({ created: 1, disposed: 1 }) - }) - - it('defers a reentrant detach until the creation dispatch unwinds', async () => { + it('defers detach requested by a creation listener until that dispatch unwinds', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) const order: string[] = [] - const agent = stubAgent('reentrant-detach') - const detach = ctx.agents.enter(agent) - - ctx.on('agent/created', (created) => { - order.push('created:first') + const agent = stubAgent('reentrant') + ctx.on('agent/created', () => { + order.push(`first:${ctx.agents.get(agent.id) === agent}`) detach() - expect(ctx.agents.get(created.id)).toBe(created) + order.push(`after-detach:${ctx.agents.get(agent.id) === agent}`) }) - ctx.on('agent/created', (created) => { - order.push('created:second') - expect(ctx.agents.get(created.id)).toBe(created) - }) - ctx.on('agent/disposed', (disposed) => { - order.push('disposed') - expect(ctx.agents.get(disposed.id)).toBeUndefined() - }) - + ctx.on('agent/created', () => void order.push(`second:${ctx.agents.get(agent.id) === agent}`)) + ctx.on('agent/disposed', () => void order.push('disposed')) + const detach = ctx.agents.enter(agent) ctx.agents.announce(agent) - - expect(order).toEqual(['created:first', 'created:second', 'disposed']) + expect(order).toEqual(['first:true', 'after-detach:true', 'second:true', 'disposed']) expect(ctx.agents.get(agent.id)).toBeUndefined() - detach() }) }) describe('agentEvents()', () => { - it('contains synchronous throws and returned-promise rejections per listener', async () => { + it('contains each synchronous throw and returned-promise rejection', async () => { const ctx = new Context() const warnings: string[] = [] - ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn - const agent = stubAgent('contained') const heard: string[] = [] - const hostile = { [Symbol.toPrimitive]() { throw new Error('cannot stringify') } } - - ctx.on('agent/status', () => { throw hostile }) + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const agent = stubAgent('event') + ctx.on('agent/status', () => { throw new Error('sync listener') }) ctx.on('agent/status', () => Promise.reject(new Error('async listener')) as never) - ctx.on('agent/status', (_subject, status) => { heard.push(status) }) + ctx.on('agent/status', (_agent, status) => void heard.push(status)) - expect(() => { agentEvents(ctx, agent).emit('agent/status', 'running') }).not.toThrow() + agentEvents(ctx, agent).emit('agent/status', 'running') await Promise.resolve() - await Promise.resolve() - expect(heard).toEqual(['running']) expect(warnings).toEqual([ - 'agent event "agent/status" listener threw: ', + 'agent event "agent/status" listener threw: Error: sync listener', 'agent event "agent/status" listener rejected: Error: async listener', ]) }) }) describe('AgentRegistry factory seam', () => { - /** A stub AgentFactory that records calls and returns a stub agent. */ function stubFactory() { const calls: { create: Array<{ ownerCtx: Context; options: CreateAgentOptions }> @@ -409,198 +151,44 @@ describe('AgentRegistry factory seam', () => { calls.create.push({ ownerCtx, options }) return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } }, - resume(ownerCtx, options) { + async resume(ownerCtx, options) { calls.resume.push({ ownerCtx, options }) - return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }) + return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } }, } return { factory, calls } } - it('create()/resume() throw when no factory is registered', async () => { + it('requires a factory and delegates through the calling context', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/) - await expect(ctx.agents.resume({ agentId: AgentId('a'), resumeSessionId: SessionId('s') })).rejects.toThrow(/no agent factory/) - }) - - it('setFactory registers a factory; create/resume delegate to it', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) const { factory, calls } = stubFactory() ctx.agents.setFactory(factory) - const created = await ctx.agents.create({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }) - expect(created.agent.id).toBe('c1') - expect(calls.create).toHaveLength(1) - expect(calls.create[0]!.ownerCtx.fiber).toBe(ctx.fiber) - expect(calls.create[0]!.options) - .toEqual({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }) - - const resumed = await ctx.agents.resume({ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') }) - expect(resumed.agent.id).toBe('r1') - expect(calls.resume).toHaveLength(1) - expect(calls.resume[0]!.ownerCtx.fiber).toBe(ctx.fiber) - expect(calls.resume[0]!.options).toEqual({ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') }) - }) - - it('passes the calling fiber to a plain factory for create and resume ownership', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - const { factory, calls } = stubFactory() - ctx.agents.setFactory(factory) let callerFiber: Context['fiber'] | undefined - - const owner = await ctx.plugin(Object.assign(async (inner: Context) => { + await ctx.plugin(Object.assign(async (inner: Context) => { callerFiber = inner.fiber - await inner.agents.create({ agentId: AgentId('owned-create'), sessionId: SessionId('owned-session') }) - await inner.agents.resume({ agentId: AgentId('owned-resume'), resumeSessionId: SessionId('persisted') }) + await inner.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') }) + await inner.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') }) }, { inject: ['agents'] })) + expect(calls.create[0]?.ownerCtx.fiber).toBe(callerFiber) + expect(calls.resume[0]?.ownerCtx.fiber).toBe(callerFiber) + }) - expect(calls.create[0]!.ownerCtx.fiber).toBe(callerFiber) - expect(calls.resume[0]!.ownerCtx.fiber).toBe(callerFiber) + it('rejects a second factory and clears the slot with its owner (HMR)', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const owner = await ctx.plugin(Object.assign((inner: Context) => { + inner.agents.setFactory(stubFactory().factory) + expect(() => inner.agents.setFactory(stubFactory().factory)).toThrow(/already registered/) + }, { inject: ['agents'] })) + await expect(ctx.agents.create({ agentId: AgentId('before'), sessionId: SessionId('before-s') })).resolves.toBeDefined() await owner.dispose() + await expect(ctx.agents.create({ agentId: AgentId('after'), sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/) }) - it('captures factory callbacks once while retaining the intentional target receiver', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - const reads = { create: 0, resume: 0 } - const receivers: unknown[] = [] - const replacements: string[] = [] - const target = { label: 'accepted-target' } as { label: string } & AgentFactory - - Object.defineProperties(target, { - createAgent: { - configurable: true, - get() { - reads.create += 1 - return function (this: typeof target, _ownerCtx: Context, options: CreateAgentOptions) { - receivers.push(this) - return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }) - } - }, - }, - resume: { - configurable: true, - get() { - reads.resume += 1 - return function (this: typeof target, _ownerCtx: Context, options: ResumeAgentOptions) { - receivers.push(this) - return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }) - } - }, - }, - }) - - ctx.agents.setFactory(target) - Object.defineProperties(target, { - createAgent: { - value: () => { - replacements.push('create') - return Promise.resolve({ agent: stubAgent('replacement'), dispose: () => Promise.resolve() }) - }, - }, - resume: { - value: () => { - replacements.push('resume') - return Promise.resolve({ agent: stubAgent('replacement'), dispose: () => Promise.resolve() }) - }, - }, - }) - - await ctx.agents.create({ agentId: AgentId('captured-create'), sessionId: SessionId('captured-session') }) - await ctx.agents.resume({ agentId: AgentId('captured-resume'), resumeSessionId: SessionId('captured-persisted') }) - - expect(reads).toEqual({ create: 1, resume: 1 }) - expect(receivers).toEqual([target, target]) - expect(replacements).toEqual([]) - }) - - it('reserves the factory slot before reading reentrant callback accessors', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - const nested = stubFactory().factory - const reads: string[] = [] - const reentrantCreate: Promise[] = [] - const target = {} as AgentFactory - - Object.defineProperties(target, { - createAgent: { - get() { - reads.push('createAgent') - expect(() => ctx.agents.setFactory(nested)).toThrow(/already registered/) - reentrantCreate.push(ctx.agents.create({ - agentId: AgentId('during-acceptance'), - sessionId: SessionId('during-acceptance-session'), - })) - return (_ownerCtx: Context, options: CreateAgentOptions) => Promise.resolve({ - agent: stubAgent(options.agentId), - dispose: () => Promise.resolve(), - }) - }, - }, - resume: { - get() { - reads.push('resume') - return (_ownerCtx: Context, options: ResumeAgentOptions) => Promise.resolve({ - agent: stubAgent(options.agentId), - dispose: () => Promise.resolve(), - }) - }, - }, - }) - - ctx.agents.setFactory(target) - expect(reentrantCreate).toHaveLength(1) - await expect(Promise.all(reentrantCreate)).rejects.toThrow(/no agent factory/) - await expect(ctx.agents.create({ - agentId: AgentId('after-acceptance'), - sessionId: SessionId('after-acceptance-session'), - })).resolves.toMatchObject({ agent: { id: 'after-acceptance' } }) - expect(reads).toEqual(['createAgent', 'resume']) - }) - - it('validates the complete factory shape when accepting it', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - - expect(() => ctx.agents.setFactory(null as unknown as AgentFactory)).toThrow(/non-null object or function/) - expect(() => ctx.agents.setFactory(42 as unknown as AgentFactory)).toThrow(/non-null object or function/) - expect(() => ctx.agents.setFactory({ resume() { return Promise.resolve() } } as unknown as AgentFactory)) - .toThrow(/createAgent must be a function/) - expect(() => ctx.agents.setFactory({ createAgent() { return Promise.resolve() } } as unknown as AgentFactory)) - .toThrow(/resume must be a function/) - - const callable = Object.assign(() => undefined, stubFactory().factory) - const dispose = ctx.agents.setFactory(callable) - await expect(ctx.agents.create({ agentId: AgentId('callable'), sessionId: SessionId('callable-session') })) - .resolves.toBeDefined() - await dispose() - }) - - it('setFactory rejects a second factory', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - ctx.agents.setFactory(stubFactory().factory) - expect(() => ctx.agents.setFactory(stubFactory().factory)).toThrow(/already registered/) - }) - - it('disposing the setFactory fiber clears the factory (HMR safety)', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - let dispose!: () => Promise | void - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - dispose = inner.agents.setFactory(stubFactory().factory) - }, { inject: ['agents'] })) - await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).resolves.toBeDefined() - void dispose - await fiber.dispose() - // factory slot cleared → create throws again - await expect(ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).rejects.toThrow(/no agent factory/) - }) - - it('canonicalizes an already traced Service factory before caller retracing', async () => { + it('canonicalizes an already traced Service before tracing it for the caller', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) const states = new WeakMap() @@ -609,71 +197,27 @@ describe('AgentRegistry factory seam', () => { super(inner, 'tracedFactory') states.set(this, []) } - private calls(): string[] { const original = (this as unknown as { [symbols.original]?: TracedFactory })[symbols.original] ?? this const calls = states.get(original) - if (calls === undefined) throw new Error('factory receiver did not canonicalize to the raw service') + if (calls === undefined) throw new Error('factory receiver was not canonicalized') return calls } - - createAgent(_ownerCtx: Context, options: CreateAgentOptions) { + async createAgent(_ownerCtx: Context, options: CreateAgentOptions) { this.calls().push('create') - return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }) + return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } } - - resume(_ownerCtx: Context, options: ResumeAgentOptions) { + async resume(_ownerCtx: Context, options: ResumeAgentOptions) { this.calls().push('resume') - return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }) + return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } } } await ctx.plugin(TracedFactory) const traced = (ctx as Context & { tracedFactory: TracedFactory }).tracedFactory ctx.agents.setFactory(traced) - - await ctx.agents.create({ agentId: AgentId('traced-create'), sessionId: SessionId('traced-session') }) - await ctx.agents.resume({ agentId: AgentId('traced-resume'), resumeSessionId: SessionId('traced-persisted') }) + await ctx.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') }) + await ctx.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') }) const raw = (traced as unknown as { [symbols.original]?: TracedFactory })[symbols.original] expect(states.get(raw!)).toEqual(['create', 'resume']) }) - - it('rolls back register and factory acceptance when their owner unloads reentrantly', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - let ownerCtx!: Context - const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['agents'] })) - const agent = stubAgent('register-unload-race') - ctx.on('agent/created', (created) => { - if (created === agent) void owner.dispose() - }) - - ownerCtx.agents.register(agent) - await owner.dispose() - expect(ctx.agents.get(agent.id)).toBeUndefined() - - let factoryOwnerCtx!: Context - const factoryOwner = await ctx.plugin(Object.assign((inner: Context) => { factoryOwnerCtx = inner }, { inject: ['agents'] })) - const target = {} as AgentFactory - Object.defineProperties(target, { - createAgent: { - get() { - void factoryOwner.dispose() - return (_inner: Context, options: CreateAgentOptions) => Promise.resolve({ - agent: stubAgent(options.agentId), - dispose: () => Promise.resolve(), - }) - }, - }, - resume: { - value: (_inner: Context, options: ResumeAgentOptions) => Promise.resolve({ - agent: stubAgent(options.agentId), - dispose: () => Promise.resolve(), - }), - }, - }) - factoryOwnerCtx.agents.setFactory(target) - await factoryOwner.dispose() - await expect(ctx.agents.create({ agentId: AgentId('after-owner'), sessionId: SessionId('after-owner-s') })) - .rejects.toThrow(/no agent factory/) - }) }) diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index 06d06ba59e..55a0591d0e 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -4,15 +4,14 @@ Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis co ## Public API -- `createScope(ctx: Context, key: ScopeKey): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). Throws on a primitive key, or when `ctx`'s fiber is disposing (`INACTIVE_EFFECT`). +- `createScope(ctx: Context, key: ScopeKey): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`). - `Scope.ctx` The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an `extend`, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins). - `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling). - `Scope.dispose(): Promise` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first. - `scopeOf(ctx: Context): ScopeKey | undefined` The tag a context (or any context derived from it) carries; `undefined` = context-global. -- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped` Build the dispatch `thisArg` for a scope-filtered event: capture and compose `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The captured base filter and the exposed composed filter are invoked through captured JavaScript primordials, and the composed filter's frozen invocation surface cannot be replaced or tampered with. The carrier uses a dedicated surrogate proxy target; ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to `base`, and callable carriers match the base's constructable/non-constructable shape. For non-overlay base-owned properties, descriptor queries preserve values and flags except that configurable is normalized to `true`, as required to report those properties through an extensible surrogate; defining through the carrier is therefore supported only with an explicit `configurable: true` descriptor, while an omitted or false flag is rejected before the base is touched. Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics). -- `Scoped` The compile-time carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. +- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics). +- `Scoped` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties. - `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name. -- `scopeHost(ctx, services)` Test/tooling host that snapshots the requested service list before activation, fails loud with stable missing-service diagnostics, and whose shared `dispose()` waits for both the host fiber and every minted scope, including a child already tearing down through `rawDispose`. ## Design contract diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 526dffbb51..e229243504 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -1,23 +1,6 @@ /** - * Scoped-context primitive: mint a Cordis context that TAGS everything - * registered through it with an opaque {@link ScopeKey}, and dispatch events so - * listeners registered through such a context fire only for their key's - * subject. Scope-aware registries (`ctx.tools`, `ctx.systemPrompt`) read the - * tag via {@link scopeOf} to file a registration in the right layer; the agent - * loop is the one scope MINTER today (one scope per live agent, key = the - * `Agent` object — see `Agent.ctx` in `@deepseek-ai/dsh-agent`), but the - * mechanism is key-agnostic by design so packages below the agent layer - * (`dsh-session`, `dsh-system-prompt`) can depend on it without a dependency - * cycle. - * - * Ownership and visibility derive from ONE fact — which context a registration - * went through: the scope's fiber owns the disposal (a `ctx.effect()`/ - * `ctx.on()`/registry call through the scoped context unwinds on - * {@link Scope.dispose}, because Cordis routes a service method's `this.ctx` - * to the ACCESSING context), and the tag decides who sees it. Splitting those - * two — an explicit `{ scope }` registration parameter — would let a caller - * express "visible to X, disposed with Y", which is almost always a bug; the - * scoped context makes it unrepresentable. + * Scoped-context primitive: mint a Cordis context that tags registrations with + * an opaque identity and build routing-only event carriers for that identity. * * @module @deepseek-ai/dsh-scope */ @@ -25,476 +8,109 @@ import type { Context, Fiber } from 'cordis' import { Context as CordisContext } from 'cordis' -// Capture the invocation primordials once. A carrier holder can reach the -// composed Context.filter function, so neither that function's mutable -// property surface nor a base filter's own `.call` may choose how listener- -// selection predicates are invoked. -const reflectApply = Reflect.apply -// eslint-disable-next-line @typescript-eslint/unbound-method -const functionCall = Function.prototype.call - -/** - * The identity a scope is keyed by. Opaque and compared by object identity — - * never inspected. The harness convention: a live `Agent` is the key of its - * own scope, so seam vocabularies that already carry the agent - * (`ToolExecution.agent`, `AssembleContext.scope`) name the layer directly. - */ +/** An opaque, identity-compared scope key. */ export type ScopeKey = object -/** The context tag {@link createScope} writes and {@link scopeOf} reads (module-private). */ +/** Context tag written by {@link createScope}. */ const kScope = Symbol('dsh.scope') -/** The carrier mark {@link scopeTarget} writes and {@link carrierKeyOf} reads (module-private). */ -const kCarrier = Symbol('dsh.scope.carrier') - declare const ScopedBrand: unique symbol /** - * A dispatch carrier built by {@link scopeTarget}: structurally the `base` it - * overlays, branded so scope-filtered events can DEMAND a carrier as their - * `this` type — passing a bare subject where a `Scoped` is required is a - * compile error, which is what makes "forgot the carrier" unrepresentable at - * dispatch sites. The brand is compile-time only; {@link isScopeCarrier} is - * the runtime counterpart (used by the dev invariants). + * A routing-only event receiver built by {@link scopeTarget}. The type + * parameter records the subject type for dispatch checking; the carrier does + * not expose the subject's properties. Event payloads carry the real subject. */ -export type Scoped = T & { readonly [ScopedBrand]: 'dsh.scope.carrier' } +export type Scoped = object & { readonly [ScopedBrand]: T } -/** - * A minted scope: the tagged context to register through, plus the disposers - * that unwind every registration made through it. - */ +/** The key associated with each carrier. Presence distinguishes an unkeyed carrier from a non-carrier. */ +const carrierKeys = new WeakMap() + +/** A minted registration scope and its quiescent disposal boundaries. */ export interface Scope { - /** - * The scoped context. Registrations through it are tagged with the scope's - * key (scope-aware registries file them in that key's layer; `ctx.on` - * listeners fire only for dispatches targeted at that key) and owned by the - * scope's fiber (disposed together on {@link dispose}). Contexts DERIVED - * from it — an `extend`, a fiber mounted under it — inherit the tag through - * the prototype chain. - */ + /** Context through which scope-owned registrations are made. */ ctx: Context - /** - * The EXACT disposer Cordis registered on the minting fiber for the scope's - * backing fiber. A composite (generator) effect that owns the scope's - * position in an ordered teardown must yield THIS function: Cordis dedupes a - * nested effect out of the parent's concurrent disposal list by function - * identity, so yielding a wrapper would leave the scope disposing as an - * unordered sibling. Callers outside a composite effect use {@link dispose}. - * @returns the backing fiber's teardown promise (undefined on a repeat call - * — Cordis effect disposers are single-shot). - */ + /** Exact Cordis disposer, used when nesting this scope in an ordered composite effect. */ rawDispose: () => Promise | void - /** - * Unwind the scope: dispose the backing fiber, running every collected - * registration disposer. Idempotent and always awaitable: repeat and racing - * calls share one completion even though the underlying Cordis disposer is - * single-shot and returns undefined after its first invocation. - * After disposal the scoped context is inert — a further registration - * through it throws Cordis's INACTIVE_EFFECT. - * @returns for the call that initiates teardown: resolves when every - * registration's disposer has settled. Every repeat/racing call awaits - * that same quiescence boundary, including when {@link rawDispose} claimed - * the underlying single-shot Cordis disposer first. - */ + /** Dispose every scope-owned registration; racing calls await the same completion. */ dispose(): Promise } -/** - * Dispose a Cordis fiber and await its lifecycle inertia even when some other - * caller claimed the single-shot raw disposer first. `Fiber.dispose()` returns - * `undefined` on a repeat call, but the fiber's `inertia` remains the - * authoritative promise while its async unload is running. - */ +/** Follow a Cordis fiber through asynchronous teardown even if its raw disposer was already claimed. */ async function quiesceFiber(fiber: Fiber): Promise { await Promise.resolve(fiber.dispose()) while (fiber.inertia !== undefined) await fiber.inertia } -/** - * The shared no-op plugin every scope fiber mounts: named so diagnostics read - * `scope` and shared so all scopes join ONE plugin runtime (Cordis deletes the - * runtime record when its last fiber disposes, so idle deployments carry no - * residue). - */ +/** Shared no-op plugin used as the backing scope fiber. */ function scope(): void {} /** - * Mint a registration scope for `key` under `ctx`. - * - * Mounts a runtime fiber (`ctx.plugin`) and tags a child of its context with - * `key`. The fiber is usable synchronously — Cordis activates it on a - * microtask, but effect collection is uid-gated (not state-gated) and service - * resolution falls through the pending fiber to the MINTING plugin's - * dependency surface, so a caller may register through {@link Scope.ctx} the - * moment this returns. - * - * Service resolution through the scoped context flows through the minting - * plugin's dependency chain (the fiber walk), regardless of what the eventual - * holder's own fiber injected — handing out the scoped context hands out that - * dependency surface; see `Agent.ctx` in `@deepseek-ai/dsh-agent` for the harness's - * contract. - * @param ctx - the context to mount the scope under; its fiber must be active - * (a disposing owner throws Cordis's INACTIVE_EFFECT), and its plugin's - * `inject` surface is what the scoped context resolves services against. - * @param key - the scope's identity ({@link ScopeKey}); must be an object - * (identity-compared), else this throws. - * @returns the tagged context plus its disposers ({@link Scope}). + * Mint a scope under `ctx`. The scoped context inherits the minting plugin's + * dependency surface and owns every registration made through it. + * @param ctx - active context whose dependency surface the scope inherits. + * @param key - opaque identity used for listener routing. + * @returns the scoped context and exact/shared disposal boundaries. */ export function createScope(ctx: Context, key: ScopeKey): Scope { - // Runtime guard behind the ScopeKey type: callers outside the typechecker - // (yml-configured plugins, JS consumers) can still pass a primitive. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if ((typeof key !== 'object' && typeof key !== 'function') || key === null) { - throw new TypeError('createScope: key must be a non-null object or function (scope keys are identity-compared)') - } const fiber = ctx.plugin(scope) const scoped: Context = fiber.ctx.extend({ [kScope]: key }) let disposing: Promise | undefined return { ctx: scoped, - // fiber.dispose IS the disposer Cordis pushed onto the minting fiber's - // disposable list — the identity a composite effect must yield (see - // Scope.rawDispose). rawDispose: fiber.dispose, - // Memoize the public boundary and explicitly follow fiber inertia: the raw - // disposer must remain the exact Cordis function for ordered composition, - // so it cannot itself be wrapped to record a raw-first invocation. dispose: () => (disposing ??= quiesceFiber(fiber)), } } /** - * Read the scope key a context is tagged with, or `undefined` for an untagged - * (context-global) context. Walks the prototype chain, so any context DERIVED - * from a scoped context — service shadows, `extend`s, fibers mounted under it - * — reads as that scope; with nested scopes the nearest tag wins. - * @param ctx - the context to inspect (typically a registry method's - * `this.ctx`, i.e. the ACCESSING context). - * @returns the key given to {@link createScope}, or `undefined` when the - * context is not derived from any scope. + * Read the nearest scope tag inherited by a context. + * @param ctx - context to inspect. + * @returns its scope key, or `undefined` for an unscoped context. */ export function scopeOf(ctx: Context): ScopeKey | undefined { - // A plain (possibly proxied) property read: symbols bypass the Cordis - // context proxy's service resolution, and Reflect walks the prototype chain. return (ctx as Context & { [kScope]?: ScopeKey })[kScope] } -/** Whether a callable has JavaScript's internal construction capability. */ -function isConstructable(value: (...args: unknown[]) => unknown): boolean { - try { - // A Proxy has [[Construct]] iff its target does. Its trap returns before - // the engine invokes `value` or reads `value.prototype`, so a hostile but - // constructable callable cannot be mistaken for a non-constructor. - Reflect.construct(new Proxy(value, { construct: () => ({}) }), []) - return true - } catch { - // The harmless outer trap leaves lack of [[Construct]] as the only failure. - return false - } -} - /** - * Build the dispatch carrier for a scope-filtered event: `base` overlaid with - * a `Context.filter` that admits a listener iff + * Build the routing receiver for a scope-filtered event. Untagged listeners + * remain global; tagged listeners run only when their key matches. A base + * Cordis filter is composed before the scope predicate. * - * - its registering context is UNTAGGED (a context-global listener — the - * compatibility default: plain plugin listeners see every subject), or - * - its tag IS `key` (a scoped listener seeing exactly its own subject), - * - * AND `base`'s own filter (a Cordis `Service`'s listener-filter check) also admits - * it. Both the captured base filter and the composed filter are invoked - * through captured JavaScript primordials, so mutating either function's - * public `.call` property cannot bypass either predicate. Dispatching with - * `key === undefined` — a subject-less dispatch, e.g. a tool call with no - * calling agent or a bare (agent-less) session's events — - * admits only untagged listeners: a scoped listener never fires for someone - * else's (or nobody's) subject. Listeners registered `{ global: true }` - * bypass all filtering (Cordis semantics). - * - * Use it as the `thisArg` of the dispatch: - * `ctx.waterfall(scopeTarget(this, exec.agent), 'tools/pre-execute', …)`. The - * carrier is a TRANSPARENT proxy over `base`: reads delegate with `base` as - * the receiver and retrieved methods are bound to `base`, so a listener may - * call subject methods through its `this` (`this.send(…)` on a - * `Scoped`) even when the subject uses native `#private` fields — a - * bare proxy receiver would throw on those. Identity is still not - * transparent: `this !== subject` and method identity varies per read; the - * subject always travels in the event's arguments. The returned carrier is - * branded {@link Scoped} and runtime-marked ({@link isScopeCarrier} / - * {@link carrierKeyOf}) so both the type system and the dev invariants can - * tell a carrier from a bare subject. Defining an ordinary property through - * the carrier is supported only when its descriptor explicitly says - * `configurable: true`; an omitted or false flag is rejected before touching - * `base`, because the extensible surrogate cannot truthfully report a new - * non-configurable base property. - * @param base - the object the event is dispatched on behalf of (the owning - * service, or the subject agent itself); its own `Context.filter` is - * preserved and composed. - * @param key - the subject's scope key, or `undefined` for a subject-less - * dispatch. - * @returns the carrier to pass as the dispatch `thisArg`. + * The receiver is deliberately opaque: listener code obtains the real subject + * from event arguments, never from `this`. + * @param base - subject or service whose existing Cordis filter is preserved. + * @param key - routed scope identity, or `undefined` for an unscoped subject. + * @returns an opaque dispatch carrier. */ export function scopeTarget(base: T, key: ScopeKey | undefined): Scoped { - const baseFilter: unknown = (base as { [CordisContext.filter]?: unknown })[CordisContext.filter] - if (baseFilter !== undefined && typeof baseFilter !== 'function') { - throw new TypeError('scope target Context.filter must be a function when present') + const baseFilter = (base as { [CordisContext.filter]?: (ctx: Context) => boolean })[CordisContext.filter] + const carrier = { + [CordisContext.filter](ctx: Context): boolean { + if (baseFilter !== undefined && !baseFilter.call(base, ctx)) return false + const tag = scopeOf(ctx) + return tag === undefined || tag === key + }, } - const filter = (ctx: Context): boolean => { - if (baseFilter && !reflectApply(functionCall, baseFilter, [base, ctx])) return false - const tag = scopeOf(ctx) - return tag === undefined || tag === key - } - // Cordis invokes a dispatch filter as `filter.call(thisArg, listenerCtx)`. - // Pin that property to the captured primordial, then freeze the callable so - // a carrier holder cannot replace it with an always-true scope bypass. - Object.defineProperty(filter, 'call', { - value: functionCall, - writable: false, - configurable: false, - }) - Object.freeze(filter) - const overlay: Record = { - [CordisContext.filter]: filter, - [kCarrier]: Object.freeze({ key }), - } - // Use a dedicated extensible proxy TARGET, never `base` itself. Proxy get - // invariants force a trap to return a base's non-configurable/non-writable - // own value verbatim; if a caller pinned Context.filter during or after - // construction, a base-target proxy would therefore silently replace the - // composed scope predicate with the caller's filter. The surrogate owns the - // two immutable overlay slots, so later descriptor changes on `base` cannot - // affect listener selection. It shares the base prototype and delegates ordinary - // reads/writes/keys to preserve the supported transparent shape. Callable - // targets use native bound built-ins so V8 contributes no user-code surface; - // the chosen built-in matches whether `base` has [[Construct]], and the traps - // below delegate the actual call/construction to `base`. - const callableBase = typeof base === 'function' - ? base as unknown as (...args: unknown[]) => unknown - : undefined - const constructable = callableBase !== undefined && isConstructable(callableBase) - const target: object = callableBase === undefined - ? {} - : constructable - ? Object.bind(undefined) - : Math.max.bind(undefined) - Reflect.setPrototypeOf(target, Reflect.getPrototypeOf(base)) - Object.defineProperties(target, { - [CordisContext.filter]: { - value: filter, - enumerable: false, - writable: false, - configurable: false, - }, - [kCarrier]: { - value: overlay[kCarrier], - enumerable: false, - writable: false, - configurable: false, - }, - }) - const carrier = new Proxy(target, { - get(target, prop) { - // The callable surrogate has engine-owned pinned properties (`prototype`, - // `caller`, …); honor those target invariants. For object carriers the - // only pinned target properties are the exact overlay values above. - const own = Reflect.getOwnPropertyDescriptor(target, prop) - const pinned = own !== undefined && own.configurable === false - && own.get === undefined && own.writable !== true - if (pinned) { - const value: unknown = Reflect.get(target, prop, target) - return value - } - const value: unknown = Reflect.get(base, prop, base) - if (typeof value !== 'function') return value - // `constructor` is looked up, never invoked as a subject method — keep - // the real one (withProps special-cases it the same way), so - // `carrier.constructor` still identifies the subject's class. - if (prop === 'constructor') return value - // `Function.prototype.bind` types as `any`; the value is structurally - // T[prop] and the trap's contract is untyped (`any`), so unknown is the - // honest safe return. - return value.bind(base) as unknown - }, - set(_target, prop, value) { - if (Object.hasOwn(overlay, prop)) return false - return Reflect.set(base, prop, value, base) - }, - has(_target, prop) { - // A Proxy may not hide a non-configurable target key. Configurable - // surrogate-only keys (bound-function name/length) are omitted; the - // base's own/inherited surface remains authoritative. - const own = Reflect.getOwnPropertyDescriptor(target, prop) - return own?.configurable === false || Reflect.has(base, prop) - }, - ownKeys(target) { - const requiredTargetKeys = Reflect.ownKeys(target).filter((prop) => { - return Reflect.getOwnPropertyDescriptor(target, prop)?.configurable === false - }) - return [...new Set([...requiredTargetKeys, ...Reflect.ownKeys(base)])] - }, - getOwnPropertyDescriptor(target, prop) { - const targetDescriptor = Reflect.getOwnPropertyDescriptor(target, prop) - if (targetDescriptor?.configurable === false) return targetDescriptor - const baseDescriptor = Reflect.getOwnPropertyDescriptor(base, prop) - if (baseDescriptor !== undefined) return { ...baseDescriptor, configurable: true } - // Configurable surrogate-only function metadata is intentionally hidden. - return undefined - }, - defineProperty(_target, prop, attributes) { - if (Object.hasOwn(overlay, prop) || attributes.configurable !== true) return false - return Reflect.defineProperty(base, prop, attributes) - }, - deleteProperty(_target, prop) { - if (Object.hasOwn(overlay, prop)) return false - return Reflect.deleteProperty(base, prop) - }, - preventExtensions() { - // Keeping the surrogate extensible is required for ownKeys to report - // caller-owned base fields that may change over the carrier's lifetime. - return false - }, - setPrototypeOf() { - // The carrier prototype and base delegation must not be split. - return false - }, - apply(_target, thisArg, args) { - const callable = callableBase as (...values: unknown[]) => unknown - const result: unknown = Reflect.apply(callable, thisArg, args) - return result - }, - construct(_target, args, newTarget) { - const constructor = callableBase as unknown as new (...values: unknown[]) => object - const result: unknown = Reflect.construct( - constructor, - args, - newTarget === carrier ? constructor : newTarget, - ) - return result as object - }, - }) - return carrier as Scoped + carrierKeys.set(carrier, key) + return carrier as unknown as Scoped } /** - * Whether `value` is a carrier built by {@link scopeTarget} — the runtime - * counterpart of the {@link Scoped} brand, used by the dev invariants to - * assert that a scope-filtered event was dispatched with a carrier and not a - * bare subject. - * @param value - the dispatch `thisArg` to test. - * @returns true iff `value` came from {@link scopeTarget}. + * Test whether a value is a scope carrier. + * @param value - dispatch receiver to inspect. + * @returns whether {@link scopeTarget} created it. */ export function isScopeCarrier(value: unknown): value is Scoped { - if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return false - // A property read checks the immutable marker owned by the surrogate target. - return (value as { [kCarrier]?: { key: ScopeKey | undefined } })[kCarrier] !== undefined + return typeof value === 'object' && value !== null && carrierKeys.has(value) } /** - * The scope key a carrier was built for — `undefined` for a subject-less - * carrier, and also `undefined` for a non-carrier (pair with - * {@link isScopeCarrier} when the distinction matters). The dev invariants - * use it to assert the carrier's key IS the subject the event's arguments - * name. - * @param value - the dispatch `thisArg` to read. - * @returns the `key` given to {@link scopeTarget}, or `undefined`. + * Read a carrier's routing key. + * @param value - dispatch receiver to inspect. + * @returns the carrier key, or `undefined` for an unkeyed/non-carrier value. */ export function carrierKeyOf(value: unknown): ScopeKey | undefined { if (!isScopeCarrier(value)) return undefined - // Optional-prop cast: the guard proves the mark is present at runtime, but - // the Scoped<> brand carries no structural kCarrier member to narrow from. - return (value as { [kCarrier]?: { key: ScopeKey | undefined } })[kCarrier]?.key -} - -/** - * A test/tooling host for minting scopes: one mounted plugin whose `inject` - * list is the service surface every scope minted through it can reach. - */ -export interface ScopeHost { - /** - * Mint a scope under the host (see {@link createScope}); the scoped context - * resolves exactly the host's injected services. - * @param key - the scope's identity ({@link ScopeKey}). - * @returns the minted scope. - */ - mint(key: ScopeKey): Scope - /** - * Dispose the host fiber and with it every scope minted through it. - * Every racing/repeat caller observes the same completion, including when a - * child's raw disposer started before host disposal. - * @returns resolves when the host and every minted scope have reached - * quiescence. - */ - dispose(): Promise -} - -/** - * Mount a scope-minting host plugin that injects `services`, THE sanctioned - * way to mint scopes in tests (production scopes are minted by the agent - * loop). Exists because the naive spelling fails confusingly twice over: - * a plugin with no `inject` mints scopes whose service reads throw Cordis's - * cryptic `cannot get property … without inject`, and a plugin whose inject - * can never be satisfied RESOLVES its fiber await without ever running the - * callback — a silent no-op host. This helper fails LOUD instead: when the - * callback did not run, it names the absent services and disposes the host. - * The service list is copied before plugin activation so caller mutation - * across the await cannot change dependency resolution or diagnostics. - * @param ctx - the context to mount the host under. - * @param services - the service names scopes minted through this host reach - * (the host plugin's `inject` list). - * @returns the host (mint scopes, dispose them all at once). - * @throws when any of `services` is not available on `ctx` — named, not the - * Cordis dead end. - */ -export async function scopeHost(ctx: Context, services: string[]): Promise { - // The inject list crosses an await before missing-service diagnostics run. - // Detach it now so caller mutation cannot change either Cordis dependency - // resolution or the names reported by this helper. - const requiredServices = [...services] - let hostCtx: Context | undefined - // A named function statement (not Object.assign({name}) — Function.name is - // read-only) so diagnostics read `scopeHost`. - function scopeHostPlugin(inner: Context): void { hostCtx = inner } - const fiber = ctx.plugin(Object.assign(scopeHostPlugin, { inject: requiredServices })) - await fiber - if (hostCtx === undefined) { - // Dependency-pending: cordis resolves the await without running the - // callback. Name the absentees and unwind the pending fiber. - const missing = requiredServices.filter(name => ctx.get(name) === undefined) - await fiber.dispose() - /* v8 ignore next -- the '(unknown)' fallback is defensive: a pending - * fiber with zero absent services cannot occur (an all-present inject - * list runs the callback) */ - const named = missing.map(name => `"${name}"`).join(', ') || '(unknown)' - throw new Error(`scopeHost: service${missing.length === 1 ? '' : 's'} ${named} not available on this context — load the providing plugin(s) before minting scopes`) - } - const host = hostCtx - const scopes = new Set() - let disposing: Promise | undefined - const dispose = async (): Promise => { - // Start every boundary before awaiting any one of them. A child whose raw - // disposer already ran is still followed through Scope.dispose(); a child - // the host unload claims first is followed through the same fiber inertia. - const tasks = [quiesceFiber(fiber), ...[...scopes].map(scope => scope.dispose())] - const results = await Promise.allSettled(tasks) - scopes.clear() - const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason as unknown] : []) - if (errors.length === 1) throw errors[0] - if (errors.length > 1) throw new AggregateError(errors, 'scopeHost: disposal failed') - } - return { - mint: (key: ScopeKey) => { - const minted = createScope(host, key) - let disposing: Promise | undefined - const tracked: Scope = { - ctx: minted.ctx, - // Preserve the exact Cordis identity: only the public shared boundary - // is wrapped to retire this child from the host's tracking set. - rawDispose: minted.rawDispose, - dispose: () => (disposing ??= minted.dispose().finally(() => { scopes.delete(tracked) })), - } - scopes.add(tracked) - return tracked - }, - dispose: () => (disposing ??= dispose()), - } + return carrierKeys.get(value) } diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 7242aa942d..0b7bbef348 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -1,569 +1,155 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' -import { carrierKeyOf, createScope, isScopeCarrier, scopeHost, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' -import type { Scope, ScopeKey, Scoped } from '@deepseek-ai/dsh-scope' +import { carrierKeyOf, createScope, isScopeCarrier, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' +import type { Scope, Scoped } from '@deepseek-ai/dsh-scope' declare module 'cordis' { interface Events { /** - * Test-only event for exercising scope-filtered dispatch. + * Test-only event for scope-filtered dispatch. * @param value - opaque payload recorded by listeners. * @mode emit */ 'scope-test/ping'(value: string): void - /** - * Test-only waterfall for exercising carrier `this` shape. - * @param value - seed value listeners may wrap. - * @mode waterfall - */ - 'scope-test/echo'(value: string, next: () => string): string } } -/** Mount a host plugin and mint a scope inside it, returning both. */ +/** Mount a host plugin and mint a scope inside it. */ async function mintScope(ctx: Context, key: object): Promise { let scope!: Scope - await ctx.plugin((inner: Context) => { - scope = createScope(inner, key) - }) + await ctx.plugin((inner: Context) => { scope = createScope(inner, key) }) return scope } describe('createScope', () => { - it('rejects primitive keys but accepts callable objects (matching ScopeKey)', async () => { + it('tags contexts and derived contexts, with the nearest tag winning', async () => { const ctx = new Context() - // Typed through `unknown` so the ScopeKey type cannot argue the assertion - // away: this test exercises exactly the callers the typechecker misses. - const badKeys: unknown[] = ['k', null] - for (const bad of badKeys) { - expect(() => createScope(ctx, bad as ScopeKey)).toThrow(/must be a non-null object or function/) - } + const outerKey = { name: 'outer' } + const innerKey = { name: 'inner' } + const outer = await mintScope(ctx, outerKey) + const inner = createScope(outer.ctx, innerKey) - const callable = Object.assign(() => {}, { nameForTest: 'callable-key' }) - const scope = await mintScope(ctx, callable) - expect(scopeOf(scope.ctx)).toBe(callable) - await scope.dispose() - }) - - it('tags the scoped context, readable through derivations (nearest tag wins)', async () => { - const ctx = new Context() - const key = { name: 'a' } - const inner = { name: 'a.inner' } - const scope = await mintScope(ctx, key) - - expect(scopeOf(scope.ctx)).toBe(key) - // An extend of the scoped context inherits the tag through the prototype chain. - expect(scopeOf(scope.ctx.extend({}))).toBe(key) - // A plain context carries no tag. expect(scopeOf(ctx)).toBeUndefined() - // A fiber mounted UNDER the scoped context reads as that scope… - let mountedCtx!: Context - await scope.ctx.plugin((c: Context) => { mountedCtx = c }) - expect(scopeOf(mountedCtx)).toBe(key) - // …and a nested scope shadows the outer tag (nearest wins). - const nested = createScope(scope.ctx, inner) - expect(scopeOf(nested.ctx)).toBe(inner) + expect(scopeOf(outer.ctx)).toBe(outerKey) + expect(scopeOf(outer.ctx.extend({}))).toBe(outerKey) + expect(scopeOf(inner.ctx)).toBe(innerKey) + + await inner.dispose() + await outer.dispose() }) - it('is usable synchronously: registrations land before the fiber activates', async () => { + it('is usable synchronously before the backing fiber activates', async () => { const ctx = new Context() const events: string[] = [] + let scope!: Scope await ctx.plugin((inner: Context) => { - const scope = createScope(inner, { name: 'sync' }) - // Same tick as createScope — no await between mint and use. - scope.ctx.effect(() => () => void events.push('effect-disposed')) - scope.ctx.on('scope-test/ping', value => void events.push(`heard:${value}`)) + scope = createScope(inner, { name: 'sync' }) + scope.ctx.effect(() => () => void events.push('disposed')) events.push('registered') }) - ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'nobody') expect(events).toEqual(['registered']) - }) - - it('dispose() unwinds registrations, is idempotent, and inerts the context', async () => { - const ctx = new Context() - const scope = await mintScope(ctx, { name: 'd' }) - const order: string[] = [] - scope.ctx.effect(() => () => void order.push('a')) - scope.ctx.effect(() => () => void order.push('b')) - await scope.dispose() - expect(order).toEqual(['b', 'a']) // LIFO within the scope fiber - - // Repeat dispose: the underlying cordis disposer returns undefined; the - // wrapper still resolves. - await expect(scope.dispose()).resolves.toBeUndefined() - // Registration through a disposed scope throws INACTIVE_EFFECT. - expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/) + expect(events).toEqual(['registered', 'disposed']) }) - it('dispose() follows a rawDispose-first race through async quiescence', async () => { + it('shares quiescence across repeat and raw-disposer-first calls', async () => { const ctx = new Context() - const scope = await mintScope(ctx, { name: 'raw-first' }) + const scope = await mintScope(ctx, { name: 'quiescence' }) const gate = Promise.withResolvers() - let cleanupFinished = false + let finished = false scope.ctx.effect(() => async () => { await gate.promise - cleanupFinished = true + finished = true }) const raw = Promise.resolve(scope.rawDispose()) - let publicSettled = false - const publicDispose = scope.dispose().then(() => { publicSettled = true }) + const publicDispose = scope.dispose() await Promise.resolve() - expect(publicSettled).toBe(false) - expect(cleanupFinished).toBe(false) - + expect(finished).toBe(false) gate.resolve(undefined) - await Promise.all([raw, publicDispose]) - expect(cleanupFinished).toBe(true) - await expect(scope.dispose()).resolves.toBeUndefined() + await Promise.all([raw, publicDispose, scope.dispose()]) + expect(finished).toBe(true) }) - it('rawDispose is the exact cordis disposer: yielding it nests the scope at its position', async () => { + it('exposes the exact raw disposer for ordered composite teardown', async () => { const ctx = new Context() const order: string[] = [] - let composite!: () => Promise | void + let dispose!: () => Promise | void await ctx.plugin((inner: Context) => { - composite = inner.effect(function* () { - yield () => void order.push('outermost') // disposed LAST + dispose = inner.effect(function* () { + yield () => void order.push('outer') const scope = createScope(inner, { name: 'nested' }) - scope.ctx.effect(() => () => void order.push('scope-registration')) - yield scope.rawDispose // disposed SECOND — nested by identity - yield () => void order.push('innermost') // disposed FIRST + scope.ctx.effect(() => () => void order.push('scope')) + yield scope.rawDispose + yield () => void order.push('inner') }) }) - await composite() - // The scope disposed exactly at its yield position (between the two - // neighbours), not as a concurrent sibling of the composite. - expect(order).toEqual(['innermost', 'scope-registration', 'outermost']) + await dispose() + expect(order).toEqual(['inner', 'scope', 'outer']) }) }) -describe('scopeTarget dispatch filtering', () => { - it('scoped listeners hear only their key; untagged listeners hear everything', async () => { +describe('scopeTarget', () => { + it('routes scoped listeners by key while untagged listeners remain global', async () => { const ctx = new Context() const keyA = { name: 'A' } const keyB = { name: 'B' } const scopeA = await mintScope(ctx, keyA) const scopeB = await mintScope(ctx, keyB) - const heard: string[] = [] ctx.on('scope-test/ping', value => void heard.push(`global:${value}`)) scopeA.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`)) scopeB.ctx.on('scope-test/ping', value => void heard.push(`B:${value}`)) - ctx.emit(scopeTarget(ctx, keyA), 'scope-test/ping', 'to-A') - ctx.emit(scopeTarget(ctx, keyB), 'scope-test/ping', 'to-B') - ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'to-nobody') + ctx.emit(scopeTarget(ctx, keyA), 'scope-test/ping', 'a') + ctx.emit(scopeTarget(ctx, keyB), 'scope-test/ping', 'b') + ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'none') - expect(heard).toEqual([ - 'global:to-A', 'A:to-A', - 'global:to-B', 'B:to-B', - 'global:to-nobody', - ]) + expect(heard).toEqual(['global:a', 'A:a', 'global:b', 'B:b', 'global:none']) + await Promise.all([scopeA.dispose(), scopeB.dispose()]) }) - it('{ global: true } listeners bypass scope filtering entirely', async () => { + it('preserves a base Cordis filter and its receiver', async () => { const ctx = new Context() - const keyA = { name: 'A' } - const scopeA = await mintScope(ctx, keyA) - const heard: string[] = [] - scopeA.ctx.on('scope-test/ping', value => void heard.push(`escape:${value}`), { global: true }) - - ctx.emit(scopeTarget(ctx, { name: 'other' }), 'scope-test/ping', 'foreign') - ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'nobody') - expect(heard).toEqual(['escape:foreign', 'escape:nobody']) - }) - - it("composes the base's own Context.filter (a rejecting base filter wins)", async () => { - const ctx = new Context() - const keyA = { name: 'A' } - const scopeA = await mintScope(ctx, keyA) + const key = { name: 'A' } + const scope = await mintScope(ctx, key) const heard: string[] = [] ctx.on('scope-test/ping', value => void heard.push(`global:${value}`)) - scopeA.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`)) - - // A base whose own filter rejects every listener context: nothing fires, - // scoped or not — the scope predicate never overrides the base's veto. - const vetoBase = { [Context.filter]: () => false } - ctx.emit(scopeTarget(vetoBase, keyA), 'scope-test/ping', 'vetoed') - expect(heard).toEqual([]) - - // A base whose filter accepts delegates to the scope predicate, with the - // real base preserved as its `this` receiver. - let baseReceiverWasOpen = false - const openBase = { + scope.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`)) + let receiverMatches = false + const base = { [Context.filter](this: object): boolean { - baseReceiverWasOpen = this === openBase - return true + receiverMatches = this === base + return false }, } - ctx.emit(scopeTarget(openBase, keyA), 'scope-test/ping', 'open') - expect(heard).toEqual(['global:open', 'A:open']) - expect(baseReceiverWasOpen).toBe(true) - // A function's public `.call` property is not its invocation semantics. - // An always-true replacement must not override the base predicate's veto. - const tamperedVeto = (): boolean => false - Object.defineProperty(tamperedVeto, 'call', { value: () => true }) - ctx.emit(scopeTarget({ [Context.filter]: tamperedVeto }, keyA), 'scope-test/ping', 'tampered-veto') - expect(heard).toEqual(['global:open', 'A:open']) + ctx.emit(scopeTarget(base, key), 'scope-test/ping', 'vetoed') + expect(heard).toEqual([]) + expect(receiverMatches).toBe(true) + await scope.dispose() }) - it('pins the exposed composed filter invocation so a carrier holder cannot bypass isolation', async () => { + it('{ global: true } listeners retain Cordis global-listener semantics', async () => { const ctx = new Context() - const keyA = { name: 'A' } - const keyB = { name: 'B' } - const scopeA = await mintScope(ctx, keyA) - const scopeB = await mintScope(ctx, keyB) + const scope = await mintScope(ctx, { name: 'A' }) const heard: string[] = [] - ctx.on('scope-test/ping', value => void heard.push(`global:${value}`)) - scopeA.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`)) - scopeB.ctx.on('scope-test/ping', value => void heard.push(`B:${value}`)) - - const carrier = scopeTarget(ctx, keyA) - const exposedFilter: unknown = Reflect.get(carrier, Context.filter) - expect(typeof exposedFilter).toBe('function') - const filter = exposedFilter as ((ctx: Context) => boolean) & { call: (...args: unknown[]) => unknown } - const primordialCall: unknown = Reflect.get(Function.prototype, 'call') - expect(Object.getOwnPropertyDescriptor(filter, 'call')).toMatchObject({ - value: primordialCall, - writable: false, - configurable: false, - }) - expect(Object.isFrozen(filter)).toBe(true) - expect(Reflect.set(filter, 'call', () => true)).toBe(false) - expect(Reflect.defineProperty(filter, 'call', { value: () => true })).toBe(false) - - ctx.emit(carrier, 'scope-test/ping', 'still-A-only') - expect(heard).toEqual(['global:still-A-only', 'A:still-A-only']) + scope.ctx.on('scope-test/ping', value => void heard.push(value), { global: true }) + ctx.emit(scopeTarget(ctx, { name: 'other' }), 'scope-test/ping', 'foreign') + ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'none') + expect(heard).toEqual(['foreign', 'none']) + await scope.dispose() }) - it('keeps listener `this` base-shaped through the carrier (waterfall)', async () => { - const ctx = new Context() - const base = { label: 'the-base' } - let seenLabel: string | undefined - ctx.on('scope-test/echo', function (this: { label: string }, value, next) { - seenLabel = this.label - return `${next()}+${value}` - }) - const result = ctx.waterfall(scopeTarget(base, undefined), 'scope-test/echo', 'v', () => 'seed') - expect(result).toBe('seed+v') - expect(seenLabel).toBe('the-base') - }) - - it('is transparent for subjects with native #private fields: methods and getters through the carrier reach the real object', () => { - // The ds-review-bot regression: cordis hands the carrier to listeners as - // `this` (typed Scoped), so subject method calls through it are a - // supported shape. A proxy that delegates with the PROXY as receiver - // (cordis withProps) throws TypeError on any native #private the method - // or getter touches; the carrier must delegate with the BASE as receiver - // and bind retrieved methods to it. - class Subject { - #count = 0 - bump(): number { return ++this.#count } - get count(): number { return this.#count } - } - const subject = new Subject() - const carrier = scopeTarget(subject, subject) - expect(carrier.bump()).toBe(1) // method call: bound to the base - expect(subject.count).toBe(1) // ...and it mutated the REAL object - expect(carrier.count).toBe(1) // getter: runs with the base as receiver - // The get trap returns the method already bound to the base; - // detachability IS the assertion. - // eslint-disable-next-line @typescript-eslint/unbound-method - const detached = carrier.bump - expect(detached()).toBe(2) - }) - - it('delegates the ordinary reflective surface while keeping overlays immutable', () => { - const frozenFn = (): string => 'frozen' - const base: { mutable: number; pinned: () => string; toString: () => string } = { - mutable: 0, - pinned: frozenFn, - toString: () => 'base-str', - } - Object.defineProperty(base, 'pinned', { value: frozenFn, writable: false, configurable: false }) - const carrier = scopeTarget(base, undefined) - carrier.mutable = 7 - expect(base.mutable).toBe(7) // sets land on the base, not a detached overlay - // The surrogate target frees reads from the base property's proxy - // invariant, so even a frozen own method can be safely bound to the base. - expect(carrier.pinned).not.toBe(frozenFn) - expect(carrier.pinned()).toBe('frozen') - // The overlay literal inherits Object.prototype; hasOwn (not `in`) keeps - // it from shadowing the subject's own prototype-surface members. - expect(String(carrier)).toBe('base-str') - expect('mutable' in carrier).toBe(true) - expect(Object.hasOwn(carrier, 'mutable')).toBe(true) - expect(Object.keys(carrier)).toEqual(['mutable', 'pinned', 'toString']) - Object.defineProperty(carrier, 'extra', { value: 1, configurable: true }) - expect((base as typeof base & { extra?: number }).extra).toBe(1) - expect(delete (carrier as typeof carrier & { extra?: number }).extra).toBe(true) - - // A non-configurable property cannot be reflected truthfully through the - // extensible surrogate. Reject before mutating the delegated base; an - // omitted `configurable` has JavaScript's false default and is rejected too. - expect(Reflect.defineProperty(carrier, 'sealed', { value: 1, configurable: false })).toBe(false) - expect(Object.hasOwn(base, 'sealed')).toBe(false) - expect(Reflect.defineProperty(carrier, 'default-sealed', { value: 2 })).toBe(false) - expect(Object.hasOwn(base, 'default-sealed')).toBe(false) - expect(Reflect.preventExtensions(carrier)).toBe(false) - expect(Reflect.setPrototypeOf(carrier, null)).toBe(false) - }) - - it('keeps isolation when the base filter is pinned before, during, or after construction', async () => { - const ctx = new Context() - const keyA = { name: 'A' } - const keyB = { name: 'B' } - const scopeA = await mintScope(ctx, keyA) - const scopeB = await mintScope(ctx, keyB) - const heard: string[] = [] - ctx.on('scope-test/ping', value => void heard.push(`global:${value}`)) - scopeA.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`)) - scopeB.ctx.on('scope-test/ping', value => void heard.push(`B:${value}`)) - const pinnedFilter = (): boolean => true - - const pinnedData = {} - Object.defineProperty(pinnedData, Context.filter, { - value: pinnedFilter, - writable: false, - configurable: false, - }) - const pinnedCarrier = scopeTarget(pinnedData, keyA) - ctx.emit(pinnedCarrier, 'scope-test/ping', 'before') - - const duringRead = {} - Object.defineProperty(duringRead, Context.filter, { - configurable: true, - get() { - Object.defineProperty(duringRead, Context.filter, { - value: pinnedFilter, - writable: false, - configurable: false, - }) - return pinnedFilter - }, - }) - ctx.emit(scopeTarget(duringRead, keyA), 'scope-test/ping', 'during') - - const pinnedAfter = { [Context.filter]: pinnedFilter } - const afterCarrier = scopeTarget(pinnedAfter, keyA) - Object.defineProperty(pinnedAfter, Context.filter, { - value: pinnedFilter, - writable: false, - configurable: false, - }) - ctx.emit(afterCarrier, 'scope-test/ping', 'after') - - const pinnedGetterless = {} - Object.defineProperty(pinnedGetterless, Context.filter, { set(_value: unknown) {}, configurable: false }) - ctx.emit(scopeTarget(pinnedGetterless, keyA), 'scope-test/ping', 'getterless') - - expect(heard).toEqual([ - 'global:before', 'A:before', - 'global:during', 'A:during', - 'global:after', 'A:after', - 'global:getterless', 'A:getterless', - ]) - expect((pinnedCarrier as Record)[Context.filter]).not.toBe(pinnedFilter) - expect(Reflect.set(pinnedCarrier, Context.filter, pinnedFilter)).toBe(false) - expect(Reflect.defineProperty(pinnedCarrier, Context.filter, { value: pinnedFilter })).toBe(false) - expect(Reflect.deleteProperty(pinnedCarrier, Context.filter)).toBe(false) - - expect(() => scopeTarget({ [Context.filter]: 1 }, { name: 'A' })).toThrow( - /Context\.filter must be a function/, - ) - }) - - it('preserves callable and constructable bases', () => { - function Subject(this: { value?: number }, value: number): number { - if (new.target) { - this.value = value - return value - } - return value * 2 - } - const carrier = scopeTarget(Subject as typeof Subject & (new (value: number) => { value: number }), { - name: 'callable', - }) - - const called: unknown = Reflect.apply(carrier, { value: 0 }, [3]) - expect(called).toBe(6) - const instance = new carrier(4) - expect(instance).toBeInstanceOf(Subject) - expect(instance.value).toBe(4) - const prototypeDescriptor = Object.getOwnPropertyDescriptor(carrier, 'prototype') - const subjectPrototype: unknown = Reflect.get(Subject, 'prototype') - expect(prototypeDescriptor?.configurable).toBe(true) - expect(prototypeDescriptor?.value).toBe(subjectPrototype) - class Derived extends carrier {} - const derived = new Derived(5) - expect(derived).toBeInstanceOf(Derived) - expect(derived).toBeInstanceOf(Subject) - expect(derived.value).toBe(5) - expect(isScopeCarrier(carrier)).toBe(true) - }) - - it('matches non-constructable and bound-constructor function shapes', () => { - const arrow = (value: number): number => value + 1 - const arrowCarrier = scopeTarget(arrow, { name: 'arrow' }) - const arrowResult: unknown = Reflect.apply(arrowCarrier, undefined, [2]) - expect(arrowResult).toBe(3) - expect('prototype' in arrowCarrier).toBe(false) - expect(Object.getOwnPropertyDescriptor(arrowCarrier, 'prototype')).toBeUndefined() - expect(() => { Reflect.construct(arrowCarrier, []) }).toThrow(TypeError) - - class Subject { - constructor(readonly value: number) {} - } - const bound = Subject.bind(undefined, 7) - const boundCarrier = scopeTarget(bound, { name: 'bound-constructor' }) - expect('prototype' in boundCarrier).toBe(false) - expect(Object.getOwnPropertyDescriptor(boundCarrier, 'prototype')).toBeUndefined() - const instance = new boundCarrier() - expect(instance).toBeInstanceOf(Subject) - expect(instance.value).toBe(7) - }) - - it('detects construction without reading a hostile base prototype', () => { - class Subject { - constructor(readonly value: number) {} - } - let prototypeReads = 0 - const hostile = new Proxy(Subject, { - get(target, prop, receiver) { - if (prop === 'prototype') { - prototypeReads += 1 - throw new Error('hostile prototype getter') - } - return Reflect.get(target, prop, receiver) as unknown - }, - }) - - const carrier = scopeTarget(hostile, { name: 'hostile-constructor' }) - expect(prototypeReads).toBe(0) - const instance: unknown = Reflect.construct(carrier, [9], Subject) - expect(instance).toBeInstanceOf(Subject) - expect(instance).toMatchObject({ value: 9 }) - expect(prototypeReads).toBe(0) - }) - - it('keeps the real constructor: class identity survives the carrier', () => { - class Subject { work(): string { return 'w' } } - const subject = new Subject() - const carrier = scopeTarget(subject, subject) - // `constructor` is looked up, never invoked as a subject method — binding - // it would break `carrier.constructor === Subject` for no benefit. - expect(carrier.constructor).toBe(Subject) - }) -}) - -describe('carrier marks', () => { - it('isScopeCarrier / carrierKeyOf distinguish carriers, keys, and bare subjects', () => { - const base = { name: 'base' } + it('uses an opaque branded carrier with a separately tracked key', () => { const key = { name: 'key' } - const keyed = scopeTarget(base, key) - const subjectless = scopeTarget(base, undefined) - - expect(isScopeCarrier(keyed)).toBe(true) - expect(carrierKeyOf(keyed)).toBe(key) - expect(isScopeCarrier(subjectless)).toBe(true) - expect(carrierKeyOf(subjectless)).toBeUndefined() - - expect(isScopeCarrier(base)).toBe(false) - expect(carrierKeyOf(base)).toBeUndefined() - expect(isScopeCarrier(null)).toBe(false) - expect(isScopeCarrier('x')).toBe(false) - }) - - it('brands the carrier type (compile-time)', () => { - const base = { name: 'base' } - const carrier = scopeTarget(base, undefined) - expectTypeOf(carrier).toExtend>() - // A bare subject is NOT assignable where a carrier is demanded. - expectTypeOf(base).not.toExtend>() - }) -}) - -describe('scopeHost', () => { - it('mints scopes that reach the injected services; dispose unwinds them all', async () => { - const ctx = new Context() - ctx.provide('answers', { value: 42 }) - const host = await scopeHost(ctx, ['answers']) - const scope = host.mint({ name: 'a' }) - expect((scope.ctx as Context & { answers: { value: number } }).answers.value).toBe(42) - const order: string[] = [] - scope.ctx.effect(() => () => void order.push('scoped-disposed')) - await host.dispose() - expect(order).toEqual(['scoped-disposed']) - expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/) - }) - - it('dispose waits for a child whose raw disposer won the race', async () => { - const ctx = new Context() - ctx.provide('answers', { value: 42 }) - const host = await scopeHost(ctx, ['answers']) - const scope = host.mint({ name: 'raw-first-child' }) - const gate = Promise.withResolvers() - let cleanupFinished = false - scope.ctx.effect(() => async () => { - await gate.promise - cleanupFinished = true - }) - - const raw = Promise.resolve(scope.rawDispose()) - let hostSettled = false - const hostDispose = host.dispose().then(() => { hostSettled = true }) - await Promise.resolve() - expect(hostSettled).toBe(false) - - gate.resolve(undefined) - await Promise.all([raw, hostDispose]) - expect(cleanupFinished).toBe(true) - await expect(host.dispose()).resolves.toBeUndefined() - }) - - it('reaches every child before surfacing one or multiple disposal failures', async () => { - const oneCtx = new Context() - oneCtx.provide('answers', { value: 42 }) - const oneHost = await scopeHost(oneCtx, ['answers']) - const one = oneHost.mint({ name: 'one' }) - one.dispose = () => Promise.reject(new Error('one failed')) - await expect(oneHost.dispose()).rejects.toThrow('one failed') - - const manyCtx = new Context() - manyCtx.provide('answers', { value: 42 }) - const manyHost = await scopeHost(manyCtx, ['answers']) - const a = manyHost.mint({ name: 'a' }) - const b = manyHost.mint({ name: 'b' }) - a.dispose = () => Promise.reject(new Error('a failed')) - b.dispose = () => Promise.reject(new Error('b failed')) - await expect(manyHost.dispose()).rejects.toMatchObject({ - name: 'AggregateError', - message: 'scopeHost: disposal failed', - errors: [expect.objectContaining({ message: 'a failed' }), expect.objectContaining({ message: 'b failed' })], - }) - }) - - it('fails LOUD naming absent services instead of resolving as a silent no-op host', async () => { - const ctx = new Context() - await expect(scopeHost(ctx, ['tools', 'systemPrompt'])) - .rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available') - }) - - it('snapshots missing-service diagnostics across the host activation await', async () => { - const ctx = new Context() - const services = ['tools', 'systemPrompt'] - const pending = scopeHost(ctx, services) - services.splice(0) - - await expect(pending) - .rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available') - }) - - it('names a single absent service in the singular', async () => { - const ctx = new Context() - await expect(scopeHost(ctx, ['tools'])).rejects.toThrow('scopeHost: service "tools" not available') + const subject = { value: 1 } + const carrier = scopeTarget(subject, key) + expect(isScopeCarrier(carrier)).toBe(true) + expect(carrierKeyOf(carrier)).toBe(key) + expect(isScopeCarrier(subject)).toBe(false) + expect(carrierKeyOf(subject)).toBeUndefined() + expect('value' in carrier).toBe(false) + expectTypeOf(carrier).toEqualTypeOf>() }) }) diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 085ebfda06..97b18b0504 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -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` 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 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 3cd1c61451..b671ad195b 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -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 { - 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 { - 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 + 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, 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 '' - } -} - -/** 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 + readonly emitCtx: Context + announced: boolean + announcing: boolean + appending: boolean + detachRequested: boolean + detach(): void } -const appendHooks = new WeakMap() -/** Identity token replaced on every store attachment or detachment. */ -const attachmentEpochs = new WeakMap() +/** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */ +const attachments = new WeakMap() /** * An event-sourced session: an append-only log of {@link SessionEvent}s. @@ -349,8 +260,6 @@ const attachmentEpochs = new WeakMap() */ 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 { - 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 - 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) + 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() - /** Ids claimed across caller-code boundaries before their exact entry commits. */ - private enteringIds = new Set() - /** The one accepted map key for each live session; never reread caller state. */ - private acceptedIds = new WeakMap() - /** Sessions whose creation announcement began and therefore require a pair. */ - private announced = new WeakSet() - /** Entries currently dispatching `session/created`; detach waits for dispatch to unwind. */ - private announcing = new WeakSet() - /** Entries accepting or publishing an append; detach waits for the boundary to unwind. */ - private appending = new WeakSet() - /** A detach requested reentrantly from creation or append publication. */ - private pendingDetach = new WeakSet() - /** Unpublished identities held across factory load/setup transactions. */ - private reservations = new Map() - /** The exact prepared object owned by each reservation capability. */ - private reservedSessions = new WeakMap() - /** - * 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>() + private store = new Map() 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-`. * @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 - 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): 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, 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 { 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 } { - 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 { diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index e564929ae0..c4838808c1 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -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 + } } /** diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index b416d15639..46015106c8 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -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' }]) }) diff --git a/packages/core/session/tests/scoped.spec.ts b/packages/core/session/tests/scoped.spec.ts index 80cc08e629..7a5e617254 100644 --- a/packages/core/session/tests/scoped.spec.ts +++ b/packages/core/session/tests/scoped.spec.ts @@ -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') diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 761f89ed2b..2204fc9027 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -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: ', - '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', ]) }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 6eed63a2f4..757ba03aac 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -4,7 +4,7 @@ import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } fr import { tmpdir } from 'node:os' import { join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { encodeSegment, logPath, scanLog, sessionDir } from '../src/format.ts' import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts' @@ -13,6 +13,13 @@ import { runCoordinatorContract, type CoordinatorFixture } from '../../session-p let root: string const dirs: string[] = [] +type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] } + +/** Test-only mutable view used to verify that backends detach returned/caller metadata. */ +function mutableHeader(header: SessionHeader): MutableSessionHeader { + return header +} + async function freshRoot(): Promise { const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-')) dirs.push(dir) @@ -255,7 +262,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { const loaded = await ctx.sessionPersistence.load(m.id) // A consumer mutates the returned meta's cwd. The backend's stored pathing // metadata must be unaffected, so a later append still finds the right log. - loaded.meta.cwd = '/evil' + mutableHeader(loaded.meta).cwd = '/evil' await ctx.sessionPersistence.append(m.id, [ { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, @@ -421,7 +428,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { const m = meta('create-snap', '/orig') const p = ctx.sessionPersistence.create(m) // Mutate the caller's meta object immediately after calling create. - m.cwd = '/mutated' + mutableHeader(m).cwd = '/mutated' await p await ctx.sessionPersistence.append(SessionId('create-snap'), oneTurnLog()) // The log materialized under the ORIGINAL cwd, not the mutated one.