refactor(core): simplify scoped agent lifecycles

This commit is contained in:
Tianyi Cui
2026-07-12 22:36:04 +08:00
parent e8fed4fb66
commit 28e04ff4fb
24 changed files with 1080 additions and 4097 deletions
+8 -6
View File
@@ -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-<uuid>` 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<AgentHandle>` — 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<AgentHandle>` — 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<AgentHandle>` — 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<AgentHandle>` — 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.)
+40 -56
View File
@@ -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<ReactLoopAgent>()
/** Sessions already claimed by a concrete driver construction. */
const claimedDriverSessions = new WeakSet<Session>()
@@ -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> | 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<void>()
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 '<unrenderable thrown value>'
}
return value instanceof Error ? value.message : String(value)
}
File diff suppressed because it is too large Load Diff
+2 -17
View File
@@ -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
+5 -55
View File
@@ -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(['<unrenderable thrown value>'])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('<unrenderable thrown value>'))
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))
+15 -161
View File
@@ -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<typeof loaded>()
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)
@@ -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[] = []
@@ -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<undefined>()
const bothStarted = Promise.withResolvers<undefined>()
let started = 0
const setup = async (): Promise<void> => {
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<Awaited<typeof first>> => 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<undefined>()
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<never>(() => {})
},
})
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<typeof ctx.agents.create>
let getterCalls = 0
const creationStarted = Promise.withResolvers<undefined>()
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<void>
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<Agent>`, 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<ReturnType<typeof ctx.agents.create>>
@@ -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<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
const sessionDisposed = Promise.withResolvers<undefined>()
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()
})
@@ -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)
})
})
+6 -6
View File
@@ -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> | 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> | 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<AgentHandle>`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<AgentHandle>` 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<AgentHandle>`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<AgentHandle>` — 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<void> }`. 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).
+4 -39
View File
@@ -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<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Promise<Awaited<Return<Events[K]>>>
/**
* 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<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Promise<Awaited<Return<Events[K]>>>
/**
* 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<Agent>, name: string, ...args: unknown[]) => Promise<never>
return await serial(carrier, name, agent, ...rest)
},
strictSerial(name, ...rest) {
return (async (): Promise<unknown> => {
// 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<Awaited<Return<Events[typeof name]>>>
},
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<Agent>, 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 '<unrenderable thrown value>'
}
}
/**
* The assembly context for one agent's prompt: the typed `agent` DX field and
* the `scope` layer selector, set together (setting `agent` without `scope`
+108 -258
View File
@@ -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> | void
readonly setup?: (agentCtx: Context) => Promise<void> | 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> | void
readonly setup?: (agentCtx: Context) => Promise<void> | 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<AgentHandle>
}
/** 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 '<unrenderable thrown value>'
}
/** All mutable lifecycle state for one exact registry entry. */
interface AgentEntry {
readonly id: AgentId
readonly agent: Agent
readonly carrier: Scoped<Agent>
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<AgentId, Agent>()
/** Ids claimed across caller-code boundaries before their exact entry commits. */
private enteringIds = new Set<AgentId>()
/** The one accepted registry key for each live agent; never reread caller state. */
private acceptedIds = new WeakMap<Agent, AgentId>()
/** Unpublished identities held across factory setup/load transactions. */
private reservations = new Map<AgentId, AgentRegistrationReservation>()
/** Entries whose `agent/created` announcement phase began. */
private announced = new WeakSet<Agent>()
/** Entries currently dispatching `agent/created`; detach waits for that dispatch to unwind. */
private announcing = new WeakSet<Agent>()
/** A detach requested reentrantly from `agent/created`. */
private pendingDetach = new WeakSet<Agent>()
/** Stable lifecycle dispatch carrier captured before an entry commits. */
private carriers = new WeakMap<Agent, Scoped<Agent>>()
private factory: AcceptedAgentFactory | typeof ACCEPTING_FACTORY | undefined
private store = new Map<AgentId, AgentEntry>()
private entries = new WeakMap<Agent, AgentEntry>()
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> | 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<AgentHandle> {
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<AgentHandle> {
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<Agent>
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<Agent>, 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 ?? '<unknown>'}" 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)
}
}
+5 -7
View File
@@ -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.
+82 -538
View File
@@ -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: <unrenderable thrown value>',
])
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: <unrenderable thrown value>',
'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<unknown>[] = []
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> | 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<object, string[]>()
@@ -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/)
})
})
+3 -4
View File
@@ -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<void>` 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<T>` 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<T>` 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<T>` 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<T>` 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
+50 -434
View File
@@ -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<T>` 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> = T & { readonly [ScopedBrand]: 'dsh.scope.carrier' }
export type Scoped<T extends object> = 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<object, ScopeKey | undefined>()
/** 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> | 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<void>
}
/**
* 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<void> {
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<void> | 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<Agent>`) 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<T extends object>(base: T, key: ScopeKey | undefined): Scoped<T> {
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<string | symbol, unknown> = {
[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<T>
carrierKeys.set(carrier, key)
return carrier as unknown as Scoped<T>
}
/**
* 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<object> {
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<void>
}
/**
* 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<ScopeHost> {
// 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<Scope>()
let disposing: Promise<void> | undefined
const dispose = async (): Promise<void> => {
// 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<void> | 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)
}
+73 -487
View File
@@ -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<Scope> {
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<undefined>()
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> | void
let dispose!: () => Promise<void> | 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<Agent>), 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<symbol, unknown>)[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<Scoped<{ name: string }>>()
// A bare subject is NOT assignable where a carrier is demanded.
expectTypeOf(base).not.toExtend<Scoped<{ name: string }>>()
})
})
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<undefined>()
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<Scoped<typeof subject>>()
})
})
+9 -10
View File
@@ -8,7 +8,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
### Public API
- `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log: the constructor reads each array entry once, then recursively validates and copies every nested value in one pass so validation and storage cannot observe different getter results or erase an exotic prototype before checking it. `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`: the store rejects an exotic metadata shell, reads every accepted field once, and constructs a detached, deep-frozen header. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber.
- `ctx.sessions.create(id?: SessionId, options?: { seed?: readonly SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. The persistence/replay seed and resulting header are validated, detached, and deep-frozen at this durable boundary. The store fills `version`/`id` and defaults `createdAt` to now; a persisted reconstruction supplies the original `createdAt` and `seedLength`. Disposed with the calling fiber.
- `ctx.sessions.flush(session: Session): Promise<void>` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). Every captured listener starts, the call waits for all of them to settle, and a failure rejects only after the other listeners finish. Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier.
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
- `ctx.sessions.get(id: SessionId): Session | undefined`
@@ -18,28 +18,27 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before the store attachment and publication hooks are removed — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect:
- `ctx.sessions.prepare(id?, options?): Session`read `options.seed`/`options.meta` once, validate and detach the metadata/header, and construct the `Session` WITHOUT entering it into the store. Same options as `create`.
- `ctx.sessions.reserve(id): SessionRegistrationReservation` — hold an unpublished id under the calling fiber and construct its one owned Session through `reservation.prepare(options?)`. `release` is the exact owner effect disposer, so the agent lifecycle can adopt it and keep the ID reserved until scope cleanup quiesces. Until that release, bare `prepare`/`create`/`enter` calls for the id reject; the factory later presents the exact capability to `enter`, making setup-time publication structurally impossible without leaking an abandoned reservation across HMR disposal.
- `ctx.sessions.enter(session, reservation?): () => void` — claim the ID across caller-controlled filter/carrier evaluation, then install the module-private append publication hooks and add the exact session under its accepted key; a reentrant same-ID entry cannot be overwritten. Returns the idempotent, exact-object-guarded DETACH disposer, which clears publication, carrier, and accepted-key state without letting a stale capability delete a replacement. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved. A factory passes the opaque capability from `reserve(id)` so setup cannot enter the reserved session or publish a same-id replacement before the owning transaction.
- `ctx.sessions.prepare(id?, options?): Session`validate durable seed/header data and construct the `Session` WITHOUT entering it into the store. Same options as `create`.
- `ctx.sessions.enter(session): () => void` — perform the authoritative ID collision check, install append publication state, and insert the exact session without announcing it. Returns an idempotent detach bound to the captured entry object, so a stale disposer cannot remove a later same-ID replacement. Concurrent same-ID preparation is allowed; only one final entry succeeds.
- `ctx.sessions.announce(session): void` — begin the one allowed `session/created` announcement for an entered session; repeat and reentrant calls reject before dispatch. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, so another creation listener cannot observe `session/disposed` before its own `session/created` callback. Detach emits `session/disposed` exactly once, including rollback after a partially delivered creation notification; a never-announced entry emits neither edge.
`dsh-agent-loop` is the canonical consumer: after unpublished agent setup it enters both session and agent before announcing either, then nests loop stop, agent removal, session detach, and scope unwind in one ordered lifecycle. The final flush therefore settles before this package detaches the session, whether teardown starts from an `AgentHandle` or owner-fiber unload.
### Live service events
The store pairs announced creation with disposal, publishes each append, and provides an awaited durability checkpoint. Before the log push it resolves the exact scoped `session/event` callback list, including development-time internal dispatch checks; substitution of the accepted session/event tuple rejects while the log is unchanged. The push is then the commit point, and callback throws or returned-promise rejections are logged and contained per observer. A committed append therefore returns normally, later observers still run, and teardown cannot interrupt an in-flight acceptance/publication boundary. Exact `session/*` signatures, modes, and scope-carrier behavior live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the append-only payload vocabulary is separately generated into the [persistence catalog](../../../docs/persistence-catalog.md). Persistence consumers write behind from the append notification and drain on the store-owned flush entry point rather than dispatching the event directly.
The store pairs announced creation with disposal, publishes each append, and provides an awaited durability checkpoint. Before the log push it resolves the scoped `session/event` callback list. The push is the commit point; callback throws or returned-promise rejections are logged and contained per observer. A committed append therefore returns normally, later observers still run, and detach waits until publication unwinds. Exact `session/*` signatures, modes, and scope-carrier behavior live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the append-only payload vocabulary is separately generated into the [persistence catalog](../../../docs/persistence-catalog.md).
### Class: `Session`
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` or surface metadata is not losslessly JSON-serializable (BigInt, function, symbol, undefined, `-0`, non-finite number, circular ref, or an exotic object like Map/Set/Date/class instance). One recursive validate-and-copy pass reads each nested value exactly once and produces the detached value that enters the log, so validation and durability cannot diverge through a stateful getter or a prototype-erasing clone. The accepted event and every nested value are deep-frozen before publication; the returned event and observer notification share that immutable owned record. An entered session pins its attachment from materialization through observer delivery, rejects if a caller getter changes that attachment, and rejects a reentrant append until the outer callback list drains; these rules prevent an event from bypassing persistence or being delivered out of log order. The log push is the commit point: a synchronous observer throw or returned-promise rejection is logged per observer and cannot turn the committed append into a caller-visible failure or starve later observers. A third parameter `opts: SurfaceIntent` carries surface metadata: `surfaceOp` and `sourceEventSeqs` are each read once, then the former controls how the event enters the surface linked list and the latter records provenance. Runtime validation accepts only `'append'` or the exact `{ op: 'replace', start, end }` record with non-negative safe-integer bounds, and provenance must be an array of non-negative safe integers; non-surface events reject either field. The marker is **required** for the five `SurfaceEventType` events (every message-producing event must declare how it joins the surface) and rejected by the compiler for non-surface types. The contract is enforced two ways: the typed overload handles a specific event literal, AND runtime checks cover widened unions and raw seed/load logs so invalid metadata can never silently enter or disappear from `deriveMessages()`.
- `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array snapshot per call over SHARED, deep-frozen `Message` objects — cloned once off the log at projection time, so a consumer can never mutate logged data (mutation throws). The surface is the single source of derived history — there is no raw-log fallback.
- `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: one event's derived message (an unfrozen clone), or `null` when it produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC).
- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. At this durable boundary, data and surface metadata are lossless-JSON snapshotted and deep-frozen. For an attached session, a reentrant append during dispatch/observer publication rejects, and detach waits for that publication to unwind. Callbacks resolve before the log push; the push is the commit point, after which each observer failure is contained independently. Runtime surface validation covers widened unions and raw seed/load logs.
- `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array per call over shared, deep-frozen `Message` objects. Each projection reuses the already deep-frozen content in its durable log event, so no second deep clone is needed and a consumer still cannot mutate logged data. The surface is the single source of derived history — there is no raw-log fallback.
- `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: a fresh message wrapper that reuses the event's already frozen content, or `null` when the event produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC).
- `session.surface: SurfaceManager` — the derived surface, lazily rebuilt from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. `surface.replaceGeneration` is the rewrite signal: bumped by every folded `replace` and by `invalidate()`, never reset, so an incremental consumer comparing generations cannot be fooled.
- `session.events` — a cached, frozen array snapshot over deep-frozen events. Repeated reads without an append return the same array; an append invalidates the cache and the next read returns a new snapshot, while earlier snapshots stay unchanged. Neither a cast nor a retained reference can push into the live log or rewrite an accepted event.
- `session.seq`, `session.id``id` is a non-writable, non-configurable runtime identity slot, not merely TypeScript-readonly.
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`) published through a non-writable, non-configurable slot. Construction validates its lossless-JSON shape and requires the header id to match `session.id`, so a caller cannot later replace or mutate persistence routing or lineage. Kept out of the event log (a storage concern, not replayable state); a minimal header (stamped with the current `SESSION_FORMAT_VERSION`) is synthesized for bare `Session` construction.
- `session.seq`, `session.id`current sequence and readonly typed identity.
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`.
### Lossless JSON utilities
+147 -468
View File
@@ -121,106 +121,40 @@ function renderTagged(tag: string, content: ContentBlock[], source: MessageSourc
]
}
/** Reject a record shell that cloning or spreading would otherwise sanitize. */
function assertPlainRecord(value: unknown, label: string): asserts value is Record<string, unknown> {
if (value === null || typeof value !== 'object') {
throw new Error(`${label} is not a plain JSON record`)
}
const prototype = Object.getPrototypeOf(value) as unknown
if (prototype !== Object.prototype && prototype !== null) {
throw new Error(`${label} is not a plain JSON record`)
}
}
/** Capture and validate the caller-owned fields that become a session header. */
function snapshotSessionMeta(source: CreateSessionOptions['meta']): NonNullable<CreateSessionOptions['meta']> {
if (source === undefined) return {}
assertPlainRecord(source, 'session metadata')
// Read each accepted field exactly once. The metadata vocabulary is scalar,
// so this plain record is already detached from the caller; cloning the
// caller's shell first would erase a class prototype before validation.
const cwd = source.cwd
const parentSession = source.parentSession
const createdAt = source.createdAt
const seedLength = source.seedLength
const accepted = {
...cwd !== undefined ? { cwd } : {},
...parentSession !== undefined ? { parentSession } : {},
...createdAt !== undefined ? { createdAt } : {},
...seedLength !== undefined ? { seedLength } : {},
}
const snapshot = snapshotJsonValue(accepted)
if (snapshot === undefined) throw new Error('session metadata is not losslessly JSON-serializable')
if (snapshot.cwd !== undefined) {
if (typeof snapshot.cwd !== 'string') throw new Error('session cwd must be a string')
if (!isAbsolute(snapshot.cwd)) {
throw new Error(`session cwd must be an absolute path, got "${snapshot.cwd}"`)
}
}
if (snapshot.parentSession !== undefined && typeof snapshot.parentSession !== 'string') {
throw new Error('session parentSession must be a string')
}
if (snapshot.createdAt !== undefined
&& (typeof snapshot.createdAt !== 'number' || !Number.isFinite(snapshot.createdAt))) {
throw new Error('session createdAt must be a finite number')
}
if (snapshot.seedLength !== undefined
&& (typeof snapshot.seedLength !== 'number' || !Number.isSafeInteger(snapshot.seedLength) || snapshot.seedLength < 0)) {
throw new Error('session seedLength must be a non-negative safe integer')
}
return snapshot
}
/** Detach, validate, and freeze the creation metadata published by a session. */
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
const input: SessionHeader = source === undefined
const input: unknown = source === undefined
? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
: source
assertPlainRecord(input, 'session header')
// Capture each property once before validation. A stateful accessor therefore
// cannot present one identity or storage location to a check and publish a
// different one afterward.
const version = input.version
const headerId = input.id
const createdAt = input.createdAt
const cwd = input.cwd
const parentSession = input.parentSession
const seedLength = input.seedLength
const accepted = {
version,
id: headerId,
createdAt,
...cwd !== undefined ? { cwd } : {},
...parentSession !== undefined ? { parentSession } : {},
...seedLength !== undefined ? { seedLength } : {},
}
const snapshot = snapshotJsonValue(accepted)
const snapshot = snapshotJsonValue(input)
if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
if (snapshot.version !== SESSION_FORMAT_VERSION) {
throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(snapshot.version)}`)
if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
throw new Error('session header is not a plain JSON record')
}
if (snapshot.id !== id) {
throw new Error(`session header id "${String(snapshot.id)}" does not match session id "${id}"`)
const record = snapshot as Record<string, unknown>
if (record.version !== SESSION_FORMAT_VERSION) {
throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`)
}
if (typeof snapshot.createdAt !== 'number' || !Number.isFinite(snapshot.createdAt)) {
if (record.id !== id) {
throw new Error(`session header id "${String(record.id)}" does not match session id "${id}"`)
}
if (typeof record.createdAt !== 'number' || !Number.isFinite(record.createdAt)) {
throw new Error('session header createdAt must be a finite number')
}
if (snapshot.cwd !== undefined) {
if (typeof snapshot.cwd !== 'string') throw new Error('session header cwd must be a string')
if (!isAbsolute(snapshot.cwd)) {
throw new Error(`session header cwd must be an absolute path, got "${snapshot.cwd}"`)
if (record.cwd !== undefined) {
if (typeof record.cwd !== 'string') throw new Error('session header cwd must be a string')
if (!isAbsolute(record.cwd)) {
throw new Error(`session header cwd must be an absolute path, got "${record.cwd}"`)
}
}
if (snapshot.parentSession !== undefined && typeof snapshot.parentSession !== 'string') {
if (record.parentSession !== undefined && typeof record.parentSession !== 'string') {
throw new Error('session header parentSession must be a string')
}
if (snapshot.seedLength !== undefined
&& (typeof snapshot.seedLength !== 'number' || !Number.isSafeInteger(snapshot.seedLength) || snapshot.seedLength < 0)) {
if (record.seedLength !== undefined
&& (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) {
throw new Error('session header seedLength must be a non-negative safe integer')
}
return deepFreeze(snapshot)
return deepFreeze(record as unknown as SessionHeader)
}
/** Validate the runtime shape of surface metadata after its JSON snapshot. */
@@ -275,25 +209,6 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
}
}
/** Render an arbitrary thrown value without allowing coercion to throw again. */
function renderThrown(value: unknown): string {
try {
return value instanceof Error ? `${value.name}: ${value.message}` : String(value)
} catch {
return '<unrenderable thrown value>'
}
}
/** Best-effort reporting that cannot re-expose an already-contained failure. */
function warnContained(ctx: Context, message: string): void {
try {
ctx.logger.warn(message)
} catch {
// contained: logger failure must not turn an observe-only callback failure
// back into a caller-visible error or an unhandled promise rejection.
}
}
type SessionCallback = (...args: unknown[]) => unknown
/** Resolve one listener snapshot, including Cordis's internal dispatch checks. */
@@ -301,13 +216,6 @@ function collectSessionCallbacks(ctx: Context, args: unknown[]): SessionCallback
return [...ctx.events.dispatch('emit', args)] as SessionCallback[]
}
/** Reject pre-commit dispatch instrumentation that substituted accepted values. */
function assertDispatchTuple(name: string, actual: unknown[], expected: unknown[]): void {
if (actual.length !== expected.length || actual.some((value, index) => value !== expected[index])) {
throw new Error(`${name} internal dispatch replaced the accepted callback tuple`)
}
}
/** Invoke one resolved observe-only listener snapshot with per-listener containment. */
function invokeContainedSessionObservers(
ctx: Context,
@@ -320,26 +228,29 @@ function invokeContainedSessionObservers(
try {
const returned: unknown = callback(...args)
void Promise.resolve(returned).catch((error: unknown) => {
warnContained(ctx, `session "${id}": ${name} listener rejected: ${renderThrown(error)}`)
ctx.logger.warn(`session "${id}": ${name} listener rejected: ${String(error)}`)
})
} catch (error: unknown) {
warnContained(ctx, `session "${id}": ${name} listener threw: ${renderThrown(error)}`)
ctx.logger.warn(`session "${id}": ${name} listener threw: ${String(error)}`)
}
}
}
interface SessionAppendHooks {
/** Keep the store attachment live through acceptance and publication. */
begin(): void
/** Resolve the exact observer list before commit; returns its contained publisher. */
prepareObservation(event: SessionEvent): () => void
/** Release the attachment barrier and honor a deferred detach. */
end(): void
/** All mutable lifecycle state for one exact store entry. */
interface SessionEntry {
readonly id: SessionId
readonly session: Session
readonly carrier: Scoped<Session>
readonly emitCtx: Context
announced: boolean
announcing: boolean
appending: boolean
detachRequested: boolean
detach(): void
}
const appendHooks = new WeakMap<Session, SessionAppendHooks>()
/** Identity token replaced on every store attachment or detachment. */
const attachmentEpochs = new WeakMap<Session, object>()
/** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */
const attachments = new WeakMap<Session, SessionEntry>()
/**
* An event-sourced session: an append-only log of {@link SessionEvent}s.
@@ -349,8 +260,6 @@ const attachmentEpochs = new WeakMap<Session, object>()
*/
export class Session {
private log: SessionEvent[] = []
/** True throughout one event's materialization, validation, commit, and publication. */
private appendInProgress = false
/**
* Derived surface — a cached linked list of message-producing events.
@@ -377,7 +286,7 @@ export class Session {
*/
readonly header: SessionHeader
constructor(public readonly id: SessionId, seed?: SessionEvent[], header?: SessionHeader) {
constructor(public readonly id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
if (seed) {
// Validate the seed to the SAME invariants `append` enforces, so a
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
@@ -387,20 +296,9 @@ export class Session {
// a bad seed would surface only later as a backend rejection or a silent
// divergence between the live log and disk.
this.log = Array.from(seed, (source, index) => {
// Spreading would erase a class instance's prototype. Reject an exotic
// event shell before that normalization can turn it into an apparently
// valid plain record; field values are still captured by the one spread
// below, so their accessors are not read twice.
assertPlainRecord(source, `seed event at index ${index}`)
// Read every enumerable event field once. Validation and snapshot
// construction must consume this same captured record: a stateful seed
// index or event getter cannot present one record to the checks and
// another to the durable log.
const event = { ...source }
// Materialize the complete accepted record in one recursive pass. A
// validate-then-structuredClone sequence would reread nested getters and
// could sanitize a class instance returned only to the clone.
const snapshot = snapshotJsonValue(event)
// The seed is a persistence/replay boundary: validate and detach the
// complete event in one lossless-JSON pass.
const snapshot = snapshotJsonValue(source)
if (snapshot === undefined) {
throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`)
}
@@ -424,14 +322,6 @@ export class Session {
})
}
this.header = snapshotSessionHeader(id, header)
// TypeScript readonly prevents ordinary typed assignment only. Pin both
// public identity bindings at runtime too: setup/plugins receive the live
// Session object, and replacing either slot would split registry keys,
// persistence routing, and the already-validated header.
Object.defineProperties(this, {
id: { value: id, enumerable: true, writable: false, configurable: false },
header: { value: this.header, enumerable: true, writable: false, configurable: false },
})
}
/** Cached immutable public snapshot of the private append-only log. */
@@ -490,88 +380,53 @@ export class Session {
data: SessionEventMap[T],
...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
): SessionEvent<T> {
if (typeof type !== 'string') {
throw new TypeError('session event type must be a string')
const surfaceOpts: SurfaceIntent | undefined = opts[0]
const surfaceMetadata = {
...surfaceOpts?.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: surfaceOpts.sourceEventSeqs },
...surfaceOpts?.surfaceOp === undefined ? {} : { surfaceOp: surfaceOpts.surfaceOp },
}
if (this.appendInProgress) {
throw new Error('session append cannot reenter while another append is being accepted or published')
const dataSnapshot = snapshotJsonValue(data)
if (dataSnapshot === undefined) {
throw new Error(`session event "${type}" carries non-JSON-serializable data`)
}
const hooks = appendHooks.get(this)
const attachmentEpoch = attachmentEpochs.get(this)
this.appendInProgress = true
const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata)
if (surfaceMetadataSnapshot === undefined) {
throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)
}
assertSurfaceMetadataShape(
type,
(surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp,
(surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs,
)
const entry = attachments.get(this)
if (entry?.appending) {
throw new Error('session append cannot reenter while another append is being published')
}
if (entry !== undefined) entry.appending = true
try {
// Start before reading caller-owned fields: a getter may request detach
// or try to append reentrantly. The attachment and sequence boundary stay
// stable until this exact acceptance attempt has either failed or reached
// every post-commit observer.
hooks?.begin()
const surfaceOpts: SurfaceIntent | undefined = opts[0]
const sourceEventSeqs = surfaceOpts?.sourceEventSeqs
const surfaceOp = surfaceOpts?.surfaceOp
// Surface-eligible events MUST carry a surfaceOp marker — the surface is the
// sole source of derived history, so a marker-less message event would be
// logged yet vanish from deriveMessages(). The typed `opts` overload makes
// the marker mandatory only when `T` is a SPECIFIC SurfaceEventType literal;
// when `T` widens to the SessionEventType union (a caller iterating raw
// events: `for (const e of log) append(e.type, e.data)`), the conditional
// rest collapses to optional and the compiler stops enforcing it. Re-check
// at runtime so that loophole can't silently drop history.
const surfaceMetadata = {
...sourceEventSeqs !== undefined ? { sourceEventSeqs } : {},
...surfaceOp !== undefined ? { surfaceOp } : {},
}
// The caller still owns the data and metadata objects and could mutate them
// after append. Materialize each accepted value exactly once while checking
// its JSON vocabulary, so the log cannot drift and a stateful getter cannot
// show one value to validation and another to a prototype-erasing clone. The
// returned event carries these SAME snapshots.
//
// Surface metadata accessors are read once into one plain record; the
// recursive snapshot then reads each nested value once as it copies it.
// Build the event shape with conditional surface fields via spreading.
// The result is cast through `unknown` because the conditional spreads
// produce an intersection type that the assignability checker can't
// narrow to a specific discriminated-union member when T is generic.
// This is a safe internal boundary: data and surface metadata are
// materialized below before the event enters the log.
const dataSnapshot = snapshotJsonValue(data)
if (dataSnapshot === undefined) {
throw new Error(`session event "${type}" carries non-JSON-serializable data`)
}
const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata)
if (surfaceMetadataSnapshot === undefined) {
throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)
}
assertSurfaceMetadataShape(
type,
(surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp,
(surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs,
)
if (appendHooks.get(this) !== hooks || attachmentEpochs.get(this) !== attachmentEpoch) {
throw new Error('session attachment changed while append input was being accepted')
}
const event = {
const event = deepFreeze({
type,
seq: this.log.length,
time: Date.now(),
data: dataSnapshot,
...surfaceMetadataSnapshot,
} as unknown as SessionEvent<T>
const acceptedEvent = deepFreeze(event)
// Resolve dispatch before the log push. Cordis runs internal/dispatch
// while producing this list; if instrumentation rejects the carrier, the
// append still fails before commit. The resolved callbacks themselves are
// observe-only and run with per-listener containment after the push.
const publish = hooks?.prepareObservation(acceptedEvent as unknown as SessionEvent)
this.log.push(acceptedEvent as unknown as SessionEvent)
} as unknown as SessionEvent<T>)
let callbacks: SessionCallback[] | undefined
const callbackArgs: unknown[] = [this, event]
if (entry !== undefined) {
callbacks = collectSessionCallbacks(entry.emitCtx, [entry.carrier, 'session/event', ...callbackArgs])
}
this.log.push(event as SessionEvent)
this.eventsSnapshot = undefined
publish?.()
return acceptedEvent
if (callbacks !== undefined && entry !== undefined) {
invokeContainedSessionObservers(entry.emitCtx, 'session/event', entry.id, callbackArgs, callbacks)
}
return event
} finally {
try {
hooks?.end()
} finally {
this.appendInProgress = false
if (entry !== undefined) {
entry.appending = false
if (entry.detachRequested && !entry.announcing) entry.detach()
}
}
}
@@ -621,10 +476,9 @@ export class Session {
* call costs O(new nodes), and a surface rewrite (a `replace`;
* {@link SurfaceManager.replaceGeneration}) rebuilds. The returned array is
* a fresh snapshot per call (later appends never grow an array a caller
* already holds); the `Message` objects in it are SHARED and **deep-frozen**
* cloned once off the log at projection time, so consumers can never
* mutate logged data, and mutation attempts throw instead of silently
* diverging replay from history.
* already holds); the `Message` objects in it are SHARED and **deep-frozen**.
* Their content reuses the already frozen durable event data, so the cache
* needs no second deep clone and consumers still cannot mutate the log.
* @returns a fresh array of the shared, frozen derived history.
*/
deriveMessages(): Message[] {
@@ -656,9 +510,10 @@ export class Session {
* The per-node pure function {@link deriveMessages} folds over the surface;
* an external reconstructor (or the dev invariant) folds the same function
* over a log prefix's surface to rebuild the exact messages any request was
* built from (the reconstructability RFC). The returned `content` is
* deep-cloned off the logged event: the log is append-only by contract, so
* no live reference to logged data leaves this boundary.
* built from (the reconstructability RFC). The returned message wrapper is
* fresh; its content reuses the logged event's already deep-frozen durable
* data, so changing the wrapper cannot rewrite the log and changing content
* throws.
* @param event - the event to project.
* @returns the derived message, or null when the event produces none.
*/
@@ -669,29 +524,29 @@ export class Session {
switch (event.type) {
case 'user/message': {
return { role: 'user', content: structuredClone(event.data.content) }
return { role: 'user', content: event.data.content }
}
case 'assistant/message': {
// Skip an empty-content assistant/message: it exists only to host a
// max-tokens step's usage and must not inject a content-less assistant
// turn into the provider transcript.
if (event.data.content.length === 0) return null
return { role: 'assistant', content: structuredClone(event.data.content) }
return { role: 'assistant', content: event.data.content }
}
case 'tool/result': {
const { callId, content, isError } = event.data
return {
role: 'user',
content: [{ type: 'tool-result', toolCallId: callId, content: structuredClone(content), isError }],
content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
}
}
case 'context/message': {
const { content, source } = event.data
return { role: 'user', content: renderTagged('context', structuredClone(content), source) }
return { role: 'user', content: renderTagged('context', content, source) }
}
case 'steering/message': {
const { content, source } = event.data
return { role: 'user', content: renderTagged('steering', structuredClone(content), source) }
return { role: 'user', content: renderTagged('steering', content, source) }
}
default:
// A non-surface event (boundary, chunk, log-only record) projects to
@@ -727,31 +582,6 @@ export class SessionForkError extends Error {
}
}
/**
* Unforgeable ownership handle for one unpublished session id. A factory keeps
* this capability across load/setup, preventing setup code from entering the
* prepared Session or publishing a replacement under the same id. Obtain it
* only from {@link SessionStore.reserve}.
*/
export interface SessionRegistrationReservation {
/** The reserved store id. */
readonly id: SessionId
/**
* Construct the one Session owned by this reservation.
* @param options - seed events and creation metadata.
* @returns the still-unpublished Session.
*/
prepare(options?: CreateSessionOptions): Session
/**
* Release the unpublished reservation; idempotent. The store also releases
* it automatically when the fiber that called `reserve` disposes. This
* function is that exact Cordis effect disposer, so an ordered lifecycle may
* yield it by identity and place release after quiescence.
* @returns nothing.
*/
release(): void
}
/**
* In-memory session store (`ctx.sessions`).
*
@@ -759,80 +589,13 @@ export interface SessionRegistrationReservation {
* subscribe to `session/event` and flush on `session/flush` / dispose.
*/
export class SessionStore extends Service {
private store = new Map<SessionId, Session>()
/** Ids claimed across caller-code boundaries before their exact entry commits. */
private enteringIds = new Set<SessionId>()
/** The one accepted map key for each live session; never reread caller state. */
private acceptedIds = new WeakMap<Session, SessionId>()
/** Sessions whose creation announcement began and therefore require a pair. */
private announced = new WeakSet<Session>()
/** Entries currently dispatching `session/created`; detach waits for dispatch to unwind. */
private announcing = new WeakSet<Session>()
/** Entries accepting or publishing an append; detach waits for the boundary to unwind. */
private appending = new WeakSet<Session>()
/** A detach requested reentrantly from creation or append publication. */
private pendingDetach = new WeakSet<Session>()
/** Unpublished identities held across factory load/setup transactions. */
private reservations = new Map<SessionId, SessionRegistrationReservation>()
/** The exact prepared object owned by each reservation capability. */
private reservedSessions = new WeakMap<SessionRegistrationReservation, Session>()
/**
* Each live session's dispatch carrier, captured at {@link enter} from the
* ENTERING context's scope tag (an agent session is entered through
* `agent.ctx` ⇒ its events dispatch in that agent's scope; a bare session ⇒
* subject-less carrier). WeakMap so a detached session drops its carrier
* with the entry.
*/
private carriers = new WeakMap<Session, Scoped<Session>>()
private store = new Map<SessionId, SessionEntry>()
private counter = 0
constructor(ctx: Context) {
super(ctx, 'sessions')
}
/**
* Reserve one unpublished session id across an asynchronous factory
* transaction. Bare `prepare`/`create`/`enter` calls for the id reject until
* release; the capability constructs exactly one Session and is passed back
* to {@link enter} at publication. The reservation belongs to the calling
* fiber, so owner unload releases an abandoned id automatically.
* @param id - the session id the transaction will publish.
* @returns the opaque reservation capability.
* @throws if the id is malformed, live, or already reserved.
*/
reserve(id: SessionId): SessionRegistrationReservation {
if (typeof id !== 'string') throw new TypeError('session id must be a string')
if (this.store.has(id) || this.reservations.has(id) || this.enteringIds.has(id)) {
throw new Error(`session "${id}" already exists or is reserved`)
}
let active = true
let prepared = false
const rawRelease = (): void => {
active = false
this.reservedSessions.delete(reservation)
this.reservations.delete(id)
}
// `release` is the exact effect disposer, so an ordered composite can
// adopt the automatic owner cleanup instead of racing it as a sibling.
const release = this.ctx.effect(() => rawRelease, `sessions.reserve(${id})`)
const reservation: SessionRegistrationReservation = Object.freeze({
id,
prepare: (options?: CreateSessionOptions) => {
if (!active) {
throw new Error(`session "${id}" reservation is no longer active`)
}
if (prepared) throw new Error(`session "${id}" reservation already prepared a session`)
prepared = true
const session = this.prepareReserved(id, options, reservation)
this.reservedSessions.set(reservation, session)
return session
},
release,
})
this.reservations.set(id, reservation)
return reservation
}
/**
* Create a session owned by the calling fiber: disposing that fiber stops
* event notification and removes the session from the store. `options.seed`
@@ -844,8 +607,8 @@ export class SessionStore extends Service {
* For an agent whose session must be torn down IN ORDER with its loop (so the
* loop's final flush is captured before the store attachment ends), do NOT use this
* — fold the session lifecycle into the agent's own effect via
* {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s
* `startOwned`).
* {@link prepare} + {@link enter} + {@link announce} (see
* `dsh-agent-loop`'s creation transaction).
*
* @param id - the session id; omitted, the store mints `session-<n>`.
* @param options - seed events and/or creation metadata for the header.
@@ -884,40 +647,23 @@ export class SessionStore extends Service {
* non-absolute path.
*/
prepare(id?: SessionId, options?: CreateSessionOptions): Session {
return this.prepareReserved(id, options)
}
/** Shared prepare implementation, optionally authorized by a reservation. */
private prepareReserved(
id?: SessionId,
options?: CreateSessionOptions,
reservation?: SessionRegistrationReservation,
): Session {
let sessionId: SessionId
if (id === undefined) {
do sessionId = SessionId(`session-${++this.counter}`)
while (this.store.has(sessionId) || this.reservations.has(sessionId))
while (this.store.has(sessionId))
} else {
sessionId = SessionId(id)
}
if (typeof sessionId !== 'string') throw new TypeError('session id must be a string')
const held = this.reservations.get(sessionId)
if (reservation === undefined && held !== undefined) {
throw new Error(`session "${sessionId}" is reserved for unpublished creation`)
}
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
const seed = options?.seed
const meta = snapshotSessionMeta(options?.meta)
const cwd = meta.cwd
const parentSession = meta.parentSession
const seedLength = meta.seedLength
const meta = options?.meta
const header: SessionHeader = {
version: SESSION_FORMAT_VERSION,
id: sessionId,
createdAt: meta.createdAt ?? Date.now(),
...cwd !== undefined ? { cwd } : {},
...parentSession !== undefined ? { parentSession } : {},
...seedLength !== undefined ? { seedLength } : {},
createdAt: meta?.createdAt ?? Date.now(),
...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
}
return new Session(sessionId, seed, header)
}
@@ -939,77 +685,31 @@ export class SessionStore extends Service {
* assume that.
*
* @param session - a {@link prepare}d session not yet in the store.
* @param reservation - the exact unpublished-id capability when a factory
* reserved this session across setup.
* @returns the detach disposer (publication hooks + store removal). When called from
* a synchronous `session/created` listener, removal and disposal wait until
* that creation dispatch unwinds.
* @throws if a session with this id is already in the store.
*/
enter(session: Session, reservation?: SessionRegistrationReservation): () => void {
enter(session: Session): () => void {
const id = session.id
if (typeof id !== 'string') throw new TypeError('session id must be a string')
const held = this.reservations.get(id)
if (reservation === undefined) {
if (held !== undefined) throw new Error(`session "${id}" is reserved for unpublished creation`)
} else if (reservation.id !== id || held !== reservation
|| this.reservedSessions.get(reservation) !== session) {
throw new Error(`session "${id}" registration reservation does not own this prepared session`)
}
if (this.store.has(id) || this.enteringIds.has(id)) {
throw new Error(`session "${id}" already exists`)
}
if (appendHooks.has(session)) throw new Error(`session "${id}" is already attached to a store`)
this.enteringIds.add(id)
// The carrier is decided HERE, once, from the ENTERING context's scope tag
// (`this.ctx` is the caller's context — the tracker mechanism): every
// session/created|event|flush dispatch for this session uses it, so the
// session's whole event feed is scope-filtered consistently. The base is
// the session itself (scoped listeners' `this` is the session).
let carrier: Scoped<Session>
try {
carrier = scopeTarget(session, scopeOf(this.ctx))
} finally {
this.enteringIds.delete(id)
}
const currentReservation = this.reservations.get(id)
if (reservation === undefined) {
/* v8 ignore next 2 -- reserve() rejects enteringIds, so carrier
* construction cannot install a new same-id reservation */
if (currentReservation !== undefined) {
throw new Error(`session "${id}" is reserved for unpublished creation`)
}
} else if (currentReservation !== reservation
|| this.reservedSessions.get(reservation) !== session) {
throw new Error(`session "${id}" registration reservation does not own this prepared session`)
}
/* v8 ignore next 1 -- enteringIds prevents a same-store commit during carrier construction */
const carrier = scopeTarget(session, scopeOf(this.ctx))
// This is the authoritative collision boundary after arbitrary unpublished
// preparation. Only one exact same-id transaction can publish.
if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
if (appendHooks.has(session)) throw new Error(`session "${id}" is already attached to a store`)
this.carriers.set(session, carrier)
const emitCtx = this.ctx
appendHooks.set(session, {
begin: () => { this.appending.add(session) },
prepareObservation(event) {
// Cordis removes carrier/name in place and exposes the remaining array
// to internal/dispatch. Resolve with a throwaway array so an internal
// checker cannot replace the tuple later observers receive.
const dispatchArgs: unknown[] = [carrier, 'session/event', session, event]
const callbackArgs: unknown[] = [session, event]
const callbacks = collectSessionCallbacks(emitCtx, dispatchArgs)
assertDispatchTuple('session/event', dispatchArgs, callbackArgs)
return () => { invokeContainedSessionObservers(emitCtx, 'session/event', id, callbackArgs, callbacks) }
},
end: () => {
this.appending.delete(session)
if (this.pendingDetach.has(session) && !this.announcing.has(session)) {
this.detachEntered(session, id, carrier)
}
},
})
attachmentEpochs.set(session, {})
this.acceptedIds.set(session, id)
this.store.set(id, session)
if (attachments.has(session)) throw new Error(`session "${id}" is already attached to a store`)
const entry: SessionEntry = {
id,
session,
carrier,
emitCtx: this.ctx,
announced: false,
announcing: false,
appending: false,
detachRequested: false,
detach: () => { this.detachEntered(entry) },
}
this.store.set(id, entry)
attachments.set(session, entry)
let entered = true
const detach = (): void => {
if (!entered) return
@@ -1017,30 +717,24 @@ export class SessionStore extends Service {
// A lifecycle listener may own the advanced detach capability. Keep the
// entry and its publication hooks live until synchronous creation or append
// publication unwinds, then publish the paired disposal edge.
if (this.announcing.has(session) || this.appending.has(session)) {
this.pendingDetach.add(session)
if (entry.announcing || entry.appending) {
entry.detachRequested = true
return
}
this.detachEntered(session, id, carrier)
entry.detach()
}
return detach
}
/** Remove one exact entered session and emit its paired disposal when announced. */
private detachEntered(session: Session, id: SessionId, carrier: Scoped<Session>): void {
this.pendingDetach.delete(session)
private detachEntered(entry: SessionEntry): void {
entry.detachRequested = false
// A stale capability cannot remove observers or storage belonging to a
// later same-id lifecycle.
/* v8 ignore next 1 -- the commit claim makes replacement impossible; this
* remains the exact-identity backstop against future mutation paths */
if (this.store.get(id) !== session || this.acceptedIds.get(session) !== id) return
const wasAnnounced = this.announced.delete(session)
appendHooks.delete(session)
attachmentEpochs.set(session, {})
this.acceptedIds.delete(session)
this.carriers.delete(session)
this.store.delete(id)
if (wasAnnounced) this.emitDisposed(session, carrier, id)
if (this.store.get(entry.id) !== entry) return
this.store.delete(entry.id)
attachments.delete(entry.session)
if (entry.announced) this.emitDisposed(entry)
}
/** Emit `session/created` exactly once for an {@link enter}ed session (with
@@ -1051,20 +745,18 @@ export class SessionStore extends Service {
* @throws if the session is not live or its announcement already began,
* including a reentrant call from a creation listener. */
announce(session: Session): void {
const { carrier, id } = this.liveEntryFor(session)
if (this.announced.has(session)) {
throw new Error(`session "${id}" was already announced`)
const entry = this.liveEntryFor(session)
if (entry.announced || entry.announcing) {
throw new Error(`session "${entry.id}" was already announced`)
}
// Mark before emit: Cordis emit may deliver to earlier listeners and then
// throw. Rollback must still pair that partial creation with disposal, and
// a listener cannot recursively create a second lifecycle edge.
this.announced.add(session)
const dispatchArgs: unknown[] = [carrier, 'session/created', session]
entry.announced = true
const callbackArgs: unknown[] = [session]
this.announcing.add(session)
entry.announcing = true
try {
const callbacks = collectSessionCallbacks(this.ctx, dispatchArgs)
assertDispatchTuple('session/created', dispatchArgs, callbackArgs)
const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/created', session])
for (const callback of callbacks) {
// Synchronous throws intentionally propagate and veto publication; the
// yielded detach then emits the paired disposal edge. An async function
@@ -1073,26 +765,23 @@ export class SessionStore extends Service {
// of becoming unhandled.
const returned: unknown = callback(...callbackArgs)
void Promise.resolve(returned).catch((error: unknown) => {
warnContained(this.ctx, `session "${id}": session/created listener rejected: ${renderThrown(error)}`)
this.ctx.logger.warn(`session "${entry.id}": session/created listener rejected: ${String(error)}`)
})
}
} finally {
this.announcing.delete(session)
if (this.pendingDetach.has(session) && !this.appending.has(session)) {
this.detachEntered(session, id, carrier)
}
entry.announcing = false
if (entry.detachRequested && !entry.appending) entry.detach()
}
}
/** Emit the paired teardown notification with per-listener containment. */
private emitDisposed(session: Session, carrier: Scoped<Session>, id: SessionId): void {
const dispatchArgs: unknown[] = [carrier, 'session/disposed', session]
const callbackArgs: unknown[] = [session]
private emitDisposed(entry: SessionEntry): void {
const callbackArgs: unknown[] = [entry.session]
try {
const callbacks = collectSessionCallbacks(this.ctx, dispatchArgs)
invokeContainedSessionObservers(this.ctx, 'session/disposed', id, callbackArgs, callbacks)
const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/disposed', entry.session])
invokeContainedSessionObservers(this.ctx, 'session/disposed', entry.id, callbackArgs, callbacks)
} catch (error: unknown) {
warnContained(this.ctx, `session "${id}": session/disposed dispatch threw: ${renderThrown(error)}`)
this.ctx.logger.warn(`session "${entry.id}": session/disposed dispatch threw: ${String(error)}`)
}
}
@@ -1109,10 +798,8 @@ export class SessionStore extends Service {
*/
async flush(session: Session): Promise<void> {
const { carrier } = this.liveEntryFor(session)
const dispatchArgs: unknown[] = [carrier, 'session/flush', session]
const callbackArgs: unknown[] = [session]
const callbacks = collectSessionCallbacks(this.ctx, dispatchArgs)
assertDispatchTuple('session/flush', dispatchArgs, callbackArgs)
const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session])
const results = await Promise.allSettled(callbacks.map((callback) => {
try {
return callback(...callbackArgs)
@@ -1127,21 +814,13 @@ export class SessionStore extends Service {
if (failure !== undefined) throw failure.reason
}
/** Return the exact live session's accepted id and carrier; detached/prepared objects reject. */
private liveEntryFor(session: Session): { id: SessionId; carrier: Scoped<Session> } {
const id = this.acceptedIds.get(session)
if (id === undefined || this.store.get(id) !== session) {
throw new Error(`session "${id ?? session.id}" is not live in this store`)
/** Return the exact live entry; detached/prepared objects reject. */
private liveEntryFor(session: Session): SessionEntry {
const entry = attachments.get(session)
if (entry === undefined || this.store.get(entry.id) !== entry) {
throw new Error(`session "${session.id}" is not live in this store`)
}
const carrier = this.carriers.get(session)
// enter() installs store + carrier in one synchronous sequence; a live
// session without one is an internal invariant violation, never fallback
// to subject-less dispatch (that would silently cross scope boundaries).
/* v8 ignore next -- enter installs store and carrier in one synchronous sequence */
if (carrier === undefined) {
throw new Error(`session "${id}" has no dispatch carrier`)
}
return { id, carrier }
return entry
}
/**
@@ -1150,7 +829,7 @@ export class SessionStore extends Service {
* @returns the session, or undefined when no live session has that id.
*/
get(id: SessionId): Session | undefined {
return this.store.get(id)
return this.store.get(id)?.session
}
/**
@@ -1158,7 +837,7 @@ export class SessionStore extends Service {
* @returns a fresh array; mutating it does not affect the store.
*/
list(): Session[] {
return [...this.store.values()]
return [...this.store.values()].map(entry => entry.session)
}
/**
@@ -1228,7 +907,7 @@ export class SessionStore extends Service {
)
}
return events.slice(0, boundary + 1).map(event => structuredClone(event))
return events.slice(0, boundary + 1)
}
private _resolveForkSource(source: SessionForkSource): Session {
+13 -8
View File
@@ -48,15 +48,15 @@ export interface SessionHeader {
* session is created. A persistence backend rejects any other version on load
* (no migration — see the constant).
*/
version: number
readonly version: number
/** The session's id (mirrors the {@link Session}'s id). */
id: SessionId
readonly id: SessionId
/** Unix epoch milliseconds when the session was created. */
createdAt: number
readonly createdAt: number
/** Absolute working directory the session was created in (if any). */
cwd?: string
readonly cwd?: string
/** The session this one was forked from (seed lineage), if any. */
parentSession?: SessionId
readonly parentSession?: SessionId
/**
* How many leading events were INHERITED via a seed rather than produced by
* this session — the seed boundary. Set when a fork seeds a child with a
@@ -66,7 +66,7 @@ export interface SessionHeader {
* harness can skip the inherited prefix when deriving the child's OWN script
* (the seeded events are the parent's, not this child's model calls).
*/
seedLength?: number
readonly seedLength?: number
}
/**
@@ -76,7 +76,7 @@ export interface SessionHeader {
*/
export interface CreateSessionOptions {
/** Events to seed the new session with (replay/fork). */
seed?: SessionEvent[]
readonly seed?: readonly SessionEvent[]
/**
* Creation metadata. The store reads this plain record and each accepted
* field once, then fills in `version`/`id` and defaults
@@ -90,7 +90,12 @@ export interface CreateSessionOptions {
* length, not the original boundary — the caller must pass the persisted
* boundary back. A fresh fork passes its actual seeded-prefix length.
*/
meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number }
readonly meta?: {
readonly cwd?: string
readonly parentSession?: SessionId
readonly createdAt?: number
readonly seedLength?: number
}
}
/**
@@ -89,15 +89,15 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
expect(session.deriveEventMessage(event)).toEqual(session.deriveMessages().at(-1))
})
it('clones content off the log: the projection never aliases the logged event', () => {
it('reuses the logged event\'s already frozen content', () => {
const session = new Session(SessionId('per-event-clone'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const event = session.append('user/message', { content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const message = session.deriveEventMessage(event)!
expect(message.content).not.toBe(event.data.content)
// deriveEventMessage returns an unfrozen clone (the cache freezes ITS
// copies); mutating it must not reach the log.
;(message.content[0] as { text: string }).text = 'mutated'
expect(message.content).toBe(event.data.content)
expect(Object.isFrozen(message.content)).toBe(true)
expect(Object.isFrozen(message.content[0])).toBe(true)
expect(() => { (message.content[0] as { text: string }).text = 'mutated' }).toThrow()
expect(session.deriveMessages().at(-1)!.content).toEqual([{ type: 'text', text: 'orig' }])
})
@@ -154,21 +154,6 @@ describe('sessions.flush()', () => {
expect(flushed).toEqual([])
})
it('rejects internal dispatch substitution before flush callbacks run', async () => {
const ctx = await mount()
const session = ctx.sessions.create()
const replacement = ctx.sessions.create()
const flushed: Session[] = []
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name === 'session/flush') args[0] = replacement
})
ctx.on('session/flush', (candidate) => { flushed.push(candidate) })
await expect(ctx.sessions.flush(session))
.rejects.toThrow('session/flush internal dispatch replaced the accepted callback tuple')
expect(flushed).toEqual([])
})
it('clears a detached carrier and rejects stale flushes', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'owner')
+10 -378
View File
@@ -129,16 +129,6 @@ describe('Session', () => {
expect(session.events).toHaveLength(0)
})
it('rejects a non-string event type without retaining or freezing caller data', () => {
const session = new Session(SessionId('invalid-event-type'))
const type = { tag: 'caller-owned' }
const appendRaw = session.append.bind(session) as unknown as (type: unknown, data: unknown) => SessionEvent
expect(() => appendRaw(type, {})).toThrow(/event type must be a string/)
expect(Object.isFrozen(type)).toBe(false)
expect(session.events).toEqual([])
})
it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
const session = new Session(SessionId('s5b'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -282,7 +272,7 @@ describe('Session', () => {
const seed: SessionEvent[] = [new SeedEvent()]
expect(() => new Session(SessionId('seed-exotic-shell'), seed))
.toThrow(/not a plain JSON record/)
.toThrow(/not losslessly JSON-serializable/)
})
it('accepts a null-prototype seed event shell as a plain JSON record', () => {
@@ -396,26 +386,6 @@ describe('Session', () => {
expect(session.events).toEqual([event])
})
it('reads surface metadata accessors once so a validated marker is logged', () => {
const session = new Session(SessionId('surface-intent-snapshot'))
let reads = 0
const intent = {
get surfaceOp(): 'append' | undefined {
reads += 1
return reads === 1 ? 'append' : undefined
},
}
const event = session.append(
'user/message',
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
intent as { surfaceOp: 'append' },
)
expect(reads).toBe(1)
expect(event.surfaceOp).toBe('append')
})
it('rejects non-JSON surface metadata before appending the event', () => {
const session = new Session(SessionId('append-bad-metadata'))
@@ -578,44 +548,10 @@ describe('Session', () => {
expect(session.header).not.toBe(input)
expect(Object.isFrozen(session.header)).toBe(true)
expect(Reflect.set(session.header, 'cwd', '/published-mutated')).toBe(false)
expect(Reflect.set(session, 'id', SessionId('redirected'))).toBe(false)
expect(Reflect.set(session, 'header', input)).toBe(false)
expect(Object.getOwnPropertyDescriptor(session, 'id')).toMatchObject({
configurable: false,
writable: false,
})
expect(Object.getOwnPropertyDescriptor(session, 'header')).toMatchObject({
configurable: false,
writable: false,
})
expect(session.id).toBe('header-owned')
expect(session.header.cwd).toBe('/accepted')
})
it('reads each supplied header field once before validation and publication', () => {
const reads = { version: 0, id: 0, createdAt: 0, cwd: 0, parentSession: 0, seedLength: 0 }
const header = {
get version() { reads.version += 1; return reads.version === 1 ? SESSION_FORMAT_VERSION : 99 },
get id() { reads.id += 1; return reads.id === 1 ? SessionId('header-once') : SessionId('drifted') },
get createdAt() { reads.createdAt += 1; return reads.createdAt === 1 ? 123 : Number.NaN },
get cwd() { reads.cwd += 1; return reads.cwd === 1 ? '/accepted' : 'relative' },
get parentSession() { reads.parentSession += 1; return reads.parentSession === 1 ? SessionId('parent') : 1n },
get seedLength() { reads.seedLength += 1; return reads.seedLength === 1 ? 0 : 1n },
} as unknown as SessionHeader
const session = new Session(SessionId('header-once'), undefined, header)
expect(reads).toEqual({ version: 1, id: 1, createdAt: 1, cwd: 1, parentSession: 1, seedLength: 1 })
expect(session.header).toEqual({
version: SESSION_FORMAT_VERSION,
id: 'header-once',
createdAt: 123,
cwd: '/accepted',
parentSession: 'parent',
seedLength: 0,
})
})
it('rejects an exotic, non-JSON, or mismatched supplied header', () => {
class ExoticHeader implements SessionHeader {
readonly version = SESSION_FORMAT_VERSION
@@ -624,7 +560,7 @@ describe('Session', () => {
}
expect(() => new Session(SessionId('header-invalid'), undefined, new ExoticHeader()))
.toThrow(/not a plain JSON record/)
.toThrow(/not losslessly JSON-serializable/)
expect(() => new Session(SessionId('header-invalid'), undefined, {
version: SESSION_FORMAT_VERSION,
id: SessionId('header-invalid'),
@@ -740,79 +676,6 @@ describe('SessionStore', () => {
expect(ctx.sessions.get(SessionId('racy'))).toBe(live)
})
it('claims an id across Context.filter evaluation before committing the exact session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const id = SessionId('reentrant-enter')
const nested = new Session(id)
const outer = new Session(id)
let nestedError = ''
let attempted = false
Object.defineProperty(outer, Context.filter, {
configurable: true,
get() {
if (!attempted) {
attempted = true
try {
ctx.sessions.enter(nested)
} catch (error: unknown) {
nestedError = String(error)
}
}
return undefined
},
})
const detach = ctx.sessions.enter(outer)
expect(nestedError).toMatch(/already exists/)
expect(ctx.sessions.get(id)).toBe(outer)
detach()
expect(ctx.sessions.get(id)).toBeUndefined()
})
it('revalidates reservation ownership after carrier construction runs caller code', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const id = SessionId('released-during-enter')
const reservation = ctx.sessions.reserve(id)
const session = reservation.prepare()
Object.defineProperty(session, Context.filter, {
configurable: true,
get() {
reservation.release()
return undefined
},
})
expect(() => ctx.sessions.enter(session, reservation)).toThrow(/does not own this prepared session/)
expect(ctx.sessions.get(id)).toBeUndefined()
})
it('rejects when carrier construction attaches the same session to another store', async () => {
const firstCtx = new Context()
const secondCtx = new Context()
await firstCtx.plugin(SessionStore)
await secondCtx.plugin(SessionStore)
const session = new Session(SessionId('cross-store-carrier'))
let attempted = false
let detachSecond = (): void => {}
Object.defineProperty(session, Context.filter, {
configurable: true,
get() {
if (!attempted) {
attempted = true
detachSecond = secondCtx.sessions.enter(session)
}
return undefined
},
})
expect(() => firstCtx.sessions.enter(session)).toThrow(/already attached to a store/)
expect(firstCtx.sessions.get(session.id)).toBeUndefined()
expect(secondCtx.sessions.get(session.id)).toBe(session)
detachSecond()
})
it('prepare() + enter() + announce() register a session and emit session/created', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -834,7 +697,7 @@ describe('SessionStore', () => {
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
})
it('captures the accepted id once and prevents simultaneous attachment to two stores', async () => {
it('prevents simultaneous attachment of one session object to two stores', async () => {
const firstCtx = new Context()
const secondCtx = new Context()
await firstCtx.plugin(SessionStore)
@@ -842,7 +705,6 @@ describe('SessionStore', () => {
const session = new Session(SessionId('owned-key'))
const detachFirst = firstCtx.sessions.enter(session)
expect(Reflect.set(session, 'id', SessionId('redirected'))).toBe(false)
expect(() => secondCtx.sessions.enter(session)).toThrow(/already attached to a store/)
expect(firstCtx.sessions.get(SessionId('owned-key'))).toBe(session)
@@ -852,69 +714,6 @@ describe('SessionStore', () => {
expect(secondCtx.sessions.get(SessionId('owned-key'))).toBe(session)
detachSecond()
expect(() => firstCtx.sessions.enter({ id: 42 } as unknown as Session)).toThrow(/id must be a string/)
})
it('uses an opaque one-session reservation to gate unpublished factory insertion', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const held = ctx.sessions.reserve(SessionId('held-session'))
expect(() => ctx.sessions.reserve(SessionId('held-session'))).toThrow(/already exists or is reserved/)
expect(() => ctx.sessions.prepare(SessionId('held-session'))).toThrow(/reserved for unpublished creation/)
expect(() => ctx.sessions.create(SessionId('held-session'))).toThrow(/reserved for unpublished creation/)
const session = held.prepare({ meta: { cwd: '/held' } })
expect(() => held.prepare()).toThrow(/already prepared/)
expect(() => ctx.sessions.enter(session)).toThrow(/reserved for unpublished creation/)
const other = ctx.sessions.reserve(SessionId('other-session'))
expect(() => ctx.sessions.enter(session, other)).toThrow(/does not own this prepared session/)
expect(() => ctx.sessions.enter(new Session(SessionId('held-session')), held))
.toThrow(/does not own this prepared session/)
const detach = ctx.sessions.enter(session, held)
ctx.sessions.announce(session)
held.release()
held.release()
expect(ctx.sessions.get(SessionId('held-session'))).toBe(session)
expect(() => ctx.sessions.reserve(SessionId('held-session'))).toThrow(/already exists or is reserved/)
detach()
other.release()
const expired = ctx.sessions.reserve(SessionId('expired-session'))
expired.release()
expect(() => expired.prepare()).toThrow(/no longer active/)
expect(() => ctx.sessions.enter(new Session(SessionId('expired-session')), expired))
.toThrow(/does not own this prepared session/)
expect(() => ctx.sessions.reserve(42 as unknown as SessionId)).toThrow(/id must be a string/)
expect(() => ctx.sessions.prepare(42 as unknown as SessionId)).toThrow(/id must be a string/)
// Auto-generated ids skip unpublished reservations just as they skip live
// store entries; no hidden collision can be published later.
const firstAuto = ctx.sessions.reserve(SessionId('session-1'))
expect(ctx.sessions.prepare().id).toBe('session-2')
firstAuto.release()
})
it('owns reservations by the calling fiber and rolls back failed ownership registration', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
let held!: import('@deepseek-ai/dsh-session').SessionRegistrationReservation
let scopedSessions!: SessionStore
const owner = await ctx.plugin(Object.assign((inner: Context) => {
scopedSessions = inner.sessions
held = inner.sessions.reserve(SessionId('fiber-held'))
}, { inject: ['sessions'] }))
expect(() => ctx.sessions.reserve(SessionId('fiber-held'))).toThrow(/already exists or is reserved/)
await owner.dispose()
const reused = ctx.sessions.reserve(SessionId('fiber-held'))
reused.release()
held.release() // idempotent after the automatic owner-disposal release
expect(() => scopedSessions.reserve(SessionId('inactive-owner'))).toThrow(/inactive context/)
const recovered = ctx.sessions.reserve(SessionId('inactive-owner'))
recovered.release()
})
it('rejects direct and reentrant repeat announcements to preserve one lifecycle pair', async () => {
@@ -1009,54 +808,14 @@ describe('SessionStore', () => {
})
})
it('reads session options and each metadata field once in prepare()', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const reads = { seed: 0, meta: 0, cwd: 0, parentSession: 0, createdAt: 0, seedLength: 0 }
const meta = {
get cwd() { reads.cwd += 1; return reads.cwd === 1 ? '/accepted' : 'relative' },
get parentSession() { reads.parentSession += 1; return reads.parentSession === 1 ? SessionId('parent') : 1n },
get createdAt() { reads.createdAt += 1; return reads.createdAt === 1 ? 123 : Number.NaN },
get seedLength() { reads.seedLength += 1; return reads.seedLength === 1 ? 0 : 1n },
}
const options = {
get seed() { reads.seed += 1; return reads.seed === 1 ? undefined : [] },
get meta() { reads.meta += 1; return reads.meta === 1 ? meta : undefined },
} as unknown as CreateSessionOptions
const session = ctx.sessions.prepare(SessionId('metadata-once'), options)
expect(reads).toEqual({ seed: 1, meta: 1, cwd: 1, parentSession: 1, createdAt: 1, seedLength: 1 })
expect(session.header).toEqual({
version: SESSION_FORMAT_VERSION,
id: 'metadata-once',
createdAt: 123,
cwd: '/accepted',
parentSession: 'parent',
seedLength: 0,
})
})
it('rejects exotic metadata before cloning can erase its prototype', async () => {
class ExoticMeta {
readonly cwd = '/accepted'
}
const ctx = new Context()
await ctx.plugin(SessionStore)
expect(() => ctx.sessions.prepare(SessionId('exotic-meta'), { meta: new ExoticMeta() }))
.toThrow(/session metadata is not a plain JSON record/)
})
it('rejects non-JSON and invalid scalar session metadata', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const cases: Array<{ meta: unknown; error: RegExp }> = [
{ meta: 1, error: /metadata is not a plain JSON record/ },
{ meta: { parentSession: 1n }, error: /metadata is not losslessly JSON-serializable/ },
{ meta: { cwd: 1 }, error: /session cwd must be a string/ },
{ meta: { parentSession: 1 }, error: /parentSession must be a string/ },
{ meta: { createdAt: '123' }, error: /createdAt must be a finite number/ },
{ meta: { parentSession: 1n }, error: /header is not losslessly JSON-serializable/ },
{ meta: { cwd: 1 }, error: /header cwd must be a string/ },
{ meta: { parentSession: 1 }, error: /header parentSession must be a string/ },
{ meta: { createdAt: '123' }, error: /header createdAt must be a finite number/ },
{ meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
@@ -1224,105 +983,6 @@ describe('SessionStore', () => {
expect(observed).toEqual([])
})
it('rejects prepend or append instrumentation that replaces the accepted observer tuple', async () => {
for (const prepend of [true, false]) {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId(`dispatch-tuple-${prepend}`))
const replacementSession = new Session(SessionId('replacement'))
const replacementEvent = {
type: 'turn/end',
seq: 99,
time: 1,
data: { turn: 99, reason: { kind: 'completed' } },
} as SessionEvent
const observed: Array<{ session: Session; event: SessionEvent }> = []
let replace = true
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event' || !replace) return
args[0] = replacementSession
args[1] = replacementEvent
}, { prepend })
ctx.on('session/event', (observedSession, event) => {
observed.push({ session: observedSession, event })
})
expect(() => session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).toThrow('session/event internal dispatch replaced the accepted callback tuple')
expect(session.events).toEqual([])
expect(observed).toEqual([])
replace = false
const appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
expect(session.events).toEqual([appended])
expect(observed).toEqual([{ session, event: appended }])
}
})
it('rejects if a bare session becomes attached while caller data is materialized', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = new Session(SessionId('attach-during-append'))
const observed: SessionEvent[] = []
let sessionEventDispatches = 0
let detach!: () => void
ctx.on('internal/dispatch', (_mode, name) => {
if (name === 'session/event') sessionEventDispatches += 1
})
ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
const data = {
get todos(): TodoItem[] {
detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
return []
},
}
expect(() => session.append('todo/write', data))
.toThrow('session attachment changed while append input was being accepted')
expect(ctx.sessions.get(session.id)).toBe(session)
expect(session.events).toEqual([])
expect(sessionEventDispatches).toBe(0)
expect(observed).toEqual([])
const appended = session.append('todo/write', { todos: [] })
expect(session.events).toEqual([appended])
expect(sessionEventDispatches).toBe(1)
expect(observed).toEqual([appended])
detach()
})
it('rejects a transient attach and detach while caller data is materialized', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = new Session(SessionId('attach-detach-during-append'))
const lifecycle: string[] = []
const observed: SessionEvent[] = []
ctx.on('session/created', () => { lifecycle.push('created') })
ctx.on('session/disposed', () => { lifecycle.push('disposed') })
ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
const data = {
get todos(): TodoItem[] {
const detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
detach()
return []
},
}
expect(() => session.append('todo/write', data))
.toThrow('session attachment changed while append input was being accepted')
expect(ctx.sessions.get(session.id)).toBeUndefined()
expect(session.events).toEqual([])
expect(lifecycle).toEqual(['created', 'disposed'])
expect(observed).toEqual([])
})
it('contains a reentrant observer append without reordering later observers', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -1342,34 +1002,10 @@ describe('SessionStore', () => {
expect(session.events).toEqual([appended])
expect(heard).toEqual([appended])
expect(warnings).toEqual([
'session "reentrant-observer": session/event listener threw: Error: session append cannot reenter while another append is being accepted or published',
'session "reentrant-observer": session/event listener threw: Error: session append cannot reenter while another append is being published',
])
})
it('keeps observer failures contained when warning output itself throws', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.logger.warn = (() => { throw new Error('logger unavailable') }) as typeof ctx.logger.warn
const session = ctx.sessions.create(SessionId('throwing-logger'))
const heard: SessionEvent[] = []
ctx.on('session/event', () => { throw new Error('sync observer') })
ctx.on('session/event', () => Promise.reject(new Error('async observer')) as never)
ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
let appended!: SessionEvent
expect(() => {
appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
}).not.toThrow()
await Promise.resolve()
await Promise.resolve()
expect(session.events).toEqual([appended])
expect(heard).toEqual([appended])
})
it('defers detach through dispatch resolution, commit, and observer publication', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -1425,12 +1061,9 @@ describe('SessionStore', () => {
await ctx.plugin(SessionStore)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const hostile = { [Symbol.toPrimitive]() { throw new Error('cannot stringify') } }
const printable = { toString: () => 'printable failure' }
const heard: string[] = []
ctx.on('session/disposed', () => { throw hostile })
ctx.on('session/disposed', () => { throw new Error('sync disposed') })
ctx.on('session/disposed', () => Promise.reject(new Error('async disposed')) as never)
ctx.on('session/disposed', () => { throw printable })
ctx.on('session/disposed', (session) => { heard.push(session.id) })
const unannounced = ctx.sessions.prepare(SessionId('never-announced'))
@@ -1447,8 +1080,7 @@ describe('SessionStore', () => {
expect(heard).toEqual(['contained-disposal'])
expect(warnings).toEqual([
'session "contained-disposal": session/disposed listener threw: <unrenderable thrown value>',
'session "contained-disposal": session/disposed listener threw: printable failure',
'session "contained-disposal": session/disposed listener threw: Error: sync disposed',
'session "contained-disposal": session/disposed listener rejected: Error: async disposed',
])
})
@@ -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<string> {
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.