fix(scope): harden final ownership boundaries
This commit is contained in:
@@ -45,7 +45,10 @@ type Tail<K extends AgentSubjectEvent> = Params<Events[K]> extends [Agent, ...in
|
||||
*/
|
||||
export interface AgentEventDispatch {
|
||||
/**
|
||||
* Fire-and-forget notification (Cordis `emit`) in the agent's scope.
|
||||
* Fire-and-forget notification in the agent's scope. Every listener is
|
||||
* invoked; synchronous throws and returned-promise rejections are logged and
|
||||
* contained per listener, so a notification cannot veto lifecycle progress
|
||||
* or starve a later observer.
|
||||
* @param name - the agent-subject event to emit.
|
||||
* @param rest - the event's arguments after the injected agent.
|
||||
*/
|
||||
@@ -96,9 +99,22 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
|
||||
// tuple — hence one contained, shape-preserving cast per method.
|
||||
return {
|
||||
emit(name, ...rest) {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function
|
||||
const emit = ctx.emit as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => void
|
||||
emit(carrier, name, agent, ...rest)
|
||||
// Cordis emit invokes callbacks through Array.map: one synchronous throw
|
||||
// starves later listeners, and returned promises are discarded. Agent
|
||||
// notifications are non-vetoing, so resolve the same filtered callback
|
||||
// set ourselves and contain both failure modes independently.
|
||||
const args: unknown[] = [carrier, name, agent, ...rest]
|
||||
const callbacks = ctx.events.dispatch('emit', args)
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
ctx.logger.warn(`agent event "${name}" listener rejected: ${renderThrown(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`agent event "${name}" listener threw: ${renderThrown(error)}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
async serial(name, ...rest) {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function
|
||||
@@ -129,6 +145,15 @@ 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`
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Context, Service } from 'cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent, AgentId, AgentOptions } from './types.ts'
|
||||
import { agentEvents } from './dispatch.ts'
|
||||
|
||||
export * from './types.ts'
|
||||
export { agentEvents, assembleContextFor } from './dispatch.ts'
|
||||
@@ -141,9 +142,9 @@ export interface AgentFactory {
|
||||
* creation notifications in order, unlocks driving at
|
||||
* `agent/session-start`, and only then starts the loop. The sequence is
|
||||
* rollback-covered, but notifications delivered before a later listener
|
||||
* failure remain observable; if agent announcement began, rollback emits
|
||||
* `agent/disposed`, while the session entry is removed without a separate
|
||||
* disposal event. The owner disposes the resolved handle to stop/drain,
|
||||
* failure remain observable; every agent or session creation announcement
|
||||
* that began is paired by `agent/disposed` or `session/disposed` during
|
||||
* rollback. The owner disposes the resolved handle to stop/drain,
|
||||
* unregister, remove the session, and unwind the scope.
|
||||
* @param options - agent/session identity, configuration, and optional setup.
|
||||
* @returns the owned handle after setup, both announcements, and loop start complete.
|
||||
@@ -164,6 +165,33 @@ export interface AgentFactory {
|
||||
/** 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>'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns nothing.
|
||||
*/
|
||||
release(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent registry (`ctx.agents`): tracks live agents so UI, hook, and
|
||||
* orchestrator plugins can find them without depending on the concrete loop
|
||||
@@ -173,6 +201,10 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug
|
||||
*/
|
||||
export class AgentRegistry extends Service {
|
||||
private store = new Map<AgentId, Agent>()
|
||||
/** 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>()
|
||||
private factory: AgentFactory | undefined
|
||||
@@ -188,6 +220,49 @@ 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)) {
|
||||
throw new Error(`agent "${id}" is already registered or reserved`)
|
||||
}
|
||||
let active = true
|
||||
const rawRelease = (): void => {
|
||||
if (!active) return
|
||||
active = false
|
||||
this.reservations.delete(id)
|
||||
}
|
||||
let disposeEffect!: () => Promise<void> | void
|
||||
const reservation: AgentRegistrationReservation = Object.freeze({
|
||||
id,
|
||||
release: () => {
|
||||
rawRelease()
|
||||
// Remove the now-inert ownership effect on manual transaction settle;
|
||||
// its cleanup is the exact idempotent raw release above.
|
||||
void disposeEffect()
|
||||
},
|
||||
})
|
||||
this.reservations.set(id, reservation)
|
||||
try {
|
||||
disposeEffect = this.ctx.effect(() => rawRelease, `agents.reserve(${id})`)
|
||||
} catch (error: unknown) {
|
||||
rawRelease()
|
||||
throw error
|
||||
}
|
||||
return reservation
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the agent-creation factory (the loop calls this on construction,
|
||||
* effect-scoped). Throws if a factory is already registered. Returns the
|
||||
@@ -269,43 +344,87 @@ 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.
|
||||
*/
|
||||
enter(agent: Agent): () => void {
|
||||
if (this.store.has(agent.id)) {
|
||||
throw new Error(`agent "${agent.id}" is already registered`)
|
||||
enter(agent: Agent, reservation?: AgentRegistrationReservation): () => 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`)
|
||||
}
|
||||
this.store.set(agent.id, agent)
|
||||
if (this.acceptedIds.has(agent)) {
|
||||
throw new Error(`agent "${id}" is already registered`)
|
||||
}
|
||||
if (this.store.has(id)) {
|
||||
throw new Error(`agent "${id}" is already registered`)
|
||||
}
|
||||
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.
|
||||
Object.defineProperty(agent, 'id', {
|
||||
value: id,
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
})
|
||||
} catch {
|
||||
// Only the engine's property-definition failure is swallowed; the stable
|
||||
// public error below is the registration contract exposed to callers.
|
||||
throw new TypeError('agent id must be installable as a stable own property')
|
||||
}
|
||||
this.store.set(id, agent)
|
||||
this.acceptedIds.set(agent, id)
|
||||
let entered = true
|
||||
return () => {
|
||||
if (!entered) return
|
||||
entered = false
|
||||
this.store.delete(agent.id)
|
||||
this.store.delete(id)
|
||||
this.acceptedIds.delete(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
|
||||
try {
|
||||
this.ctx.emit(scopeTarget(agent, agent), 'agent/disposed', agent)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`agent "${agent.id}": agent/disposed listener threw: ${String(error)}`)
|
||||
}
|
||||
agentEvents(this.ctx, agent).emit('agent/disposed')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce an agent previously inserted with {@link enter}.
|
||||
* @param agent - the live inserted agent to announce.
|
||||
* @throws if `agent` is not the exact live registry entry for its id.
|
||||
* @throws if `agent` is not the exact live registry entry for its id, or its
|
||||
* creation announcement already began (including a reentrant call from a
|
||||
* creation listener).
|
||||
*/
|
||||
announce(agent: Agent): void {
|
||||
if (this.store.get(agent.id) !== agent) {
|
||||
throw new Error(`agent "${agent.id}" is not live in this registry`)
|
||||
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`)
|
||||
}
|
||||
if (this.announced.has(agent)) {
|
||||
throw new Error(`agent "${id}" was already announced`)
|
||||
}
|
||||
// Mark before dispatch so a listener cannot recursively create a second
|
||||
// lifecycle edge; detach still pairs a partially delivered first edge.
|
||||
this.announced.add(agent)
|
||||
this.ctx.emit(scopeTarget(agent, agent), 'agent/created', agent)
|
||||
const args: unknown[] = [scopeTarget(agent, agent), 'agent/created', agent]
|
||||
for (const callback of this.ctx.events.dispatch('emit', args)) {
|
||||
// A synchronous creation failure vetoes publication and rolls back.
|
||||
// Returned-promise rejection happens after this synchronous boundary, so
|
||||
// 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)}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -288,7 +288,10 @@ declare module 'cordis' {
|
||||
* {@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.
|
||||
* to inject or queue work during startup. 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.
|
||||
* @param agent - the newly registered agent with its live session and completed setup.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
|
||||
Reference in New Issue
Block a user