fix(scope): harden final ownership boundaries
This commit is contained in:
@@ -8,10 +8,10 @@ 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 every agent-subject event goes through (carrier + injected subject in one move); `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 the factory keeps the agent and session unpublished; creation awaits setup and a same-turn owner-unload checkpoint before either creation notification or the first assembly. Setup composes, it never drives: the concrete loop rejects driving verbs until the `agent/session-start` 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 every agent-subject event goes through (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. `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.
|
||||
|
||||
- `ctx.agents.register(agent: Agent): () => Promise<void> | void` — record an **already-constructed** agent. Disposed with the calling fiber.
|
||||
- Advanced ordered lifecycle: `enter(agent): () => void` inserts without announcing, and `announce(agent)` emits `agent/created` only for that exact live entry. The async factory uses this split after setup; ordinary plugins use `register()`.
|
||||
- Advanced ordered lifecycle: `reserve(id)` returns an opaque unpublished-identity capability owned by the calling fiber (owner unload releases an abandoned reservation); `enter(agent, reservation?): () => void` inserts under one captured, runtime-pinned id without announcing; and `announce(agent)` emits `agent/created` exactly once for that exact live entry, rejecting repeat or reentrant announcement. While reserved, bare `register`/`enter` calls for the id reject, including from setup. The factory uses this split; ordinary plugins use `register()`.
|
||||
- `ctx.agents.get(id: AgentId): Agent | undefined`
|
||||
- `ctx.agents.list(): Agent[]`
|
||||
|
||||
@@ -20,7 +20,7 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-
|
||||
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.
|
||||
|
||||
- `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 and announce both session and agent, open the `agent/session-start` driving boundary, then start a new loop on the caller-supplied `sessionId`. Agent/session IDs are reserved across setup; seed rejection, setup rejection, or owner unload publishes nothing. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; an agent whose announcement began emits `agent/disposed` during that rollback. Rejects if no factory is registered.
|
||||
- `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 and announce both session and agent, open the `agent/session-start` driving boundary, then start a new loop on the caller-supplied `sessionId`. Registry/store reservation capabilities block every competing public insertion across setup; seed rejection, setup rejection, or owner unload publishes nothing. 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 → announce → session-start → loop-start boundary. The IDs are reserved across persistence load and setup; load/setup rejection or owner unload publishes nothing. Rejects if no factory is registered or session persistence is unconfigured.
|
||||
|
||||
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit plus every outstanding idle-injection flush (quiescence — 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 handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { Agent, AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { Agent, AgentId, agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
function stubAgent(rawId: string): Agent {
|
||||
const id = AgentId(rawId)
|
||||
@@ -78,6 +78,32 @@ describe('AgentRegistry', () => {
|
||||
expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('observes async agent/created rejection without rolling back or starving peers', 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) })
|
||||
|
||||
const agent = stubAgent('async-created')
|
||||
const dispose = ctx.agents.register(agent)
|
||||
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()
|
||||
})
|
||||
|
||||
it('splits insertion from announcement and makes the detach exact/idempotent', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
@@ -107,6 +133,149 @@ describe('AgentRegistry', () => {
|
||||
// no disposed-without-created notification.
|
||||
expect(disposed).toEqual([first])
|
||||
})
|
||||
|
||||
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('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 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentEvents()', () => {
|
||||
it('contains synchronous throws and returned-promise rejections per listener', 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.on('agent/status', () => Promise.reject(new Error('async listener')) as never)
|
||||
ctx.on('agent/status', (_subject, status) => { heard.push(status) })
|
||||
|
||||
expect(() => { agentEvents(ctx, agent).emit('agent/status', 'running') }).not.toThrow()
|
||||
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 rejected: Error: async listener',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentRegistry factory seam', () => {
|
||||
|
||||
Reference in New Issue
Block a user