fix(scope): harden final ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-12 05:13:17 +08:00
parent 36b8370027
commit a9cb70d896
52 changed files with 2839 additions and 514 deletions

View File

@@ -8,9 +8,9 @@ This is the only package in the harness that contains concrete loop logic. Every
### Public API
Lifecycle (scoped): programmatic creation and resume snapshot caller-owned identity/configuration data, reserve both IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. 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 installs an owner-liveness sentinel before persistence load, captures each loaded metadata field once, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. Setup calls to `send`/`steer`/`inject`/`cancel` reject structurally; load/setup rejection or owner unload publishes nothing. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope. All `agent/*` dispatches go through `agentEvents(ctx, agent)`; per-step assembly through `assembleContextFor(agent)`; the turn-end durability checkpoint through `ctx.sessions.flush(session)`.
Lifecycle (scoped): 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`. The capabilities reject competing `register`/`enter`/`prepare`/`create` calls, so setup cannot publish the factory objects or same-id replacements. 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 installs an owner-liveness sentinel before persistence load, captures each loaded metadata field once, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. The concrete agent owns runtime-pinned `id`, frozen detached `options`, `session`, and `ctx` bindings. Load/setup rejection or owner unload publishes nothing; partial creation announcements are paired during rollback. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope. All non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, which contains sync/async listener failures per observer; per-step assembly goes through `assembleContextFor(agent)`; the turn-end durability checkpoint goes through `ctx.sessions.flush(session)`.
- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent`config-driven create: an agent on a fresh per-run session id `${id}-session-<uuid>` with optional session metadata. Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
- `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):

View File

@@ -7,10 +7,10 @@
*/
import type { Context } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session } from '@deepseek-ai/dsh-session'
import { Inbox } from './inbox.ts'
@@ -65,6 +65,26 @@ 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.
* @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,
})
}
/**
* The concrete {@link Agent} implementation owned by the agent-loop plugin.
*
@@ -84,18 +104,7 @@ export class ReactLoopAgent implements Agent {
* context are mutually referential (the scope is keyed BY this agent), so
* neither can exist strictly before the other.
*/
ctx!: Context
/**
* The dispatch carrier for this agent's own emits (`agent/status`,
* `agent/queued`, `agent/error`): keyed by the agent, base = the agent
* (listener `this` is the agent). Built lazily because it is self-referential.
*/
private get carrier(): Scoped<Agent> {
return (this.#carrier ??= scopeTarget(this, this))
}
#carrier: Scoped<Agent> | undefined
declare readonly ctx: Context
private _status: AgentStatus = 'idle'
private currentAbort: AbortController | undefined
@@ -143,6 +152,16 @@ 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
@@ -161,11 +180,7 @@ export class ReactLoopAgent implements Agent {
// waiter (docs/defensive-patterns.md "contain callback exceptions" — a lifecycle await must
// not hang on one bad listener).
if (status !== 'running') this.settleIdleWaiters()
try {
this.loopCtx.emit(this.carrier, 'agent/status', this, status)
} catch (error: unknown) {
this.loopCtx.logger.warn(`agent "${this.id}": agent/status listener threw on ${status}: ${String(error)}`)
}
agentEvents(this.loopCtx, this).emit('agent/status', status)
}
/**
@@ -194,7 +209,7 @@ export class ReactLoopAgent implements Agent {
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
const source = this.resolveSource(options)
this.#inbox.enqueue({ content, source })
this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: false })
agentEvents(this.loopCtx, this).emit('agent/queued', content, { source, steering: false })
}
steer(content: ContentBlock[], options?: SendOptions): void {
@@ -203,7 +218,7 @@ export class ReactLoopAgent implements Agent {
if (this._status !== 'running') { this.send(content, options); return }
const source = this.resolveSource(options)
this.#inbox.steer({ content, source })
this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: true })
agentEvents(this.loopCtx, this).emit('agent/queued', content, { source, steering: true })
}
inject(content: ContentBlock[], options?: SendOptions): void {
@@ -269,14 +284,10 @@ export class ReactLoopAgent implements Agent {
if (turnRecorded) {
// Through the store's flush (the carrier owner), never a raw parallel.
const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => {
const err = error instanceof Error ? error : new Error(String(error))
this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${err.message}`)
try {
this.loopCtx.emit(this.carrier, 'agent/error', this, turn, 0, err)
} catch {
// contained: the failure is already logged; a throwing agent/error
// listener must not escape this fire-and-forget catch.
}
const rendered = renderThrown(error)
const err = error instanceof Error ? error : new Error(rendered)
this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`)
agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err)
})
this.pendingIdleFlushes.add(flush)
// Attach the same retirement callback to both settlement arms so even a
@@ -393,11 +404,7 @@ export class ReactLoopAgent implements Agent {
// setStatus refuses transitions out of 'disposed', so emit directly —
// 'disposed' is part of the agent/status contract. Guarded: a throwing
// listener must not break the disposal chain.
try {
this.loopCtx.emit(this.carrier, 'agent/status', this, 'disposed')
} catch {
// listener error during disposal — nothing safe left to do with it
}
agentEvents(this.loopCtx, this).emit('agent/status', 'disposed')
}
// An unexpected driver rejection must not skip registry/session/scope
// cleanup. The normal loop contains turn failures itself; allSettled is the
@@ -414,3 +421,12 @@ export class ReactLoopAgent implements Agent {
}
}
}
/** Render an arbitrary thrown value without allowing coercion to throw again. */
function renderThrown(value: unknown): string {
try {
return value instanceof Error ? value.message : String(value)
} catch {
return '<unrenderable thrown value>'
}
}

View File

@@ -13,17 +13,24 @@ import z from 'schemastery'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent'
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, AgentRegistrationReservation, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import type { Session, SessionRegistrationReservation } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { prepareReactLoopAgent, ReactLoopAgent } from './agent.ts'
import { bindReactLoopAgentContext, prepareReactLoopAgent, ReactLoopAgent } from './agent.ts'
export { ReactLoopAgent } from './agent.ts'
/** Both unpublished identity capabilities held by one factory transaction. */
interface RegistrationReservations {
agent: AgentRegistrationReservation
session: SessionRegistrationReservation
release(): void
}
declare module 'cordis' {
interface Context {
agentLoop: AgentLoop
@@ -71,10 +78,6 @@ export interface Config {
export class AgentLoop extends Service implements AgentFactory {
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
/** IDs held by unpublished async creation transactions. */
private pendingAgentIds = new Set<AgentId>()
private pendingSessionIds = new Set<SessionId>()
// The schema validates plain strings (cordis.yml config values are untyped at
// runtime); the {@link Config} TYPE declares the branded `id`/`resumeSessionId`
// because the config format is the boundary where an id enters. The brand is a
@@ -153,14 +156,19 @@ export class AgentLoop extends Service implements AgentFactory {
* @returns the running agent, owned by the calling fiber (no handle).
*/
create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
this.assertAgentIdFree(id)
const sessionId = SessionId(`${id}-session-${randomUUID()}`)
const reservations = this.reserve(id, sessionId)
// Config/programmatic path: prepare the session and let start() fold its
// lifecycle into the agent's composite effect (so a fiber unload tears the
// session + agent down as one ordered chain, capturing the loop's closing
// flush). The whole effect is owned by THIS fiber; no AgentHandle is needed.
const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta })
const { agent } = this.start(id, options, session, 'startup')
return agent
try {
const session = reservations.session.prepare({ meta })
const { agent } = this.start(id, options, session, 'startup', reservations)
return agent
} finally {
reservations.release()
}
}
/**
@@ -188,16 +196,16 @@ export class AgentLoop extends Service implements AgentFactory {
const agentOptions = structuredClone(options.agentOptions ?? {})
const seed = options.seed
const meta = options.meta
const release = this.reserve(agentId, sessionId)
const reservations = this.reserve(agentId, sessionId)
try {
const session = this.ctx.sessions.prepare(sessionId, {
const session = reservations.session.prepare({
...seed !== undefined ? { seed } : {},
...meta !== undefined ? { meta } : {},
})
// A seeded (forked) create is still a fresh start, NOT a resume.
return await this.startOwned(agentId, agentOptions, session, 'startup', setup)
return await this.startOwned(agentId, agentOptions, session, 'startup', reservations, setup)
} finally {
release()
reservations.release()
}
}
@@ -273,7 +281,7 @@ export class AgentLoop extends Service implements AgentFactory {
return transactionSettled
}, `agentLoop.resumeLoad(${agentId})`)
try {
const release = this.reserve(agentId, sessionId)
const reservations = this.reserve(agentId, sessionId)
try {
const loadTask = persistence.load(sessionId)
const { meta, events } = await Promise.race([
@@ -292,7 +300,7 @@ export class AgentLoop extends Service implements AgentFactory {
// An out-of-band direct registry/session insertion can still race this
// service's reservation, so the public enter primitives re-check exact
// liveness at publication.
const session = this.ctx.sessions.prepare(sessionId, {
const session = reservations.session.prepare({
seed: events,
meta: {
createdAt,
@@ -305,12 +313,12 @@ export class AgentLoop extends Service implements AgentFactory {
// effect before it reaches its first setup await. Only then disarm the
// load sentinel: ownership passes directly from one effect to the other
// with no disposal gap.
const starting = this.startOwned(agentId, agentOptions, session, 'resume', setup)
const starting = this.startOwned(agentId, agentOptions, session, 'resume', reservations, setup)
observingOwner = false
await disposeLoadSentinel()
return await starting
} finally {
release()
reservations.release()
}
} finally {
try {
@@ -327,29 +335,24 @@ export class AgentLoop extends Service implements AgentFactory {
}
}
/**
* Reject a duplicate agent id BEFORE the session is entered into the store, so
* a failed factory call never leaves an orphaned live session (and lazy
* persistence state) behind. `register()` enforces the same uniqueness, but
* only after the session has already entered the store.
*/
private assertAgentIdFree(id: AgentId): void {
if (this.ctx.agents.get(id) !== undefined || this.pendingAgentIds.has(id)) {
throw new Error(`agent "${id}" is already registered`)
}
}
/** Reserve both public identities for one unpublished async transaction. */
private reserve(agentId: AgentId, sessionId: SessionId): () => void {
this.assertAgentIdFree(agentId)
if (this.ctx.sessions.get(sessionId) !== undefined || this.pendingSessionIds.has(sessionId)) {
throw new Error(`session "${sessionId}" already exists`)
}
this.pendingAgentIds.add(agentId)
this.pendingSessionIds.add(sessionId)
return () => {
this.pendingAgentIds.delete(agentId)
this.pendingSessionIds.delete(sessionId)
/** Reserve both public identities in their owning registries. */
private reserve(agentId: AgentId, sessionId: SessionId): RegistrationReservations {
const agent = this.ctx.agents.reserve(agentId)
try {
const session = this.ctx.sessions.reserve(sessionId)
return {
agent,
session,
release() {
// Both owner capabilities are independently idempotent, so the
// composite needs no second state machine of its own.
session.release()
agent.release()
},
}
} catch (error: unknown) {
agent.release()
throw error
}
}
@@ -361,7 +364,12 @@ export class AgentLoop extends Service implements AgentFactory {
* `active`, unwinds the scope, and wins the race without any late Cordis
* effect collection.
*/
private prepareLifecycle(id: AgentId, options: AgentOptions, session: Session): {
private prepareLifecycle(
id: AgentId,
options: AgentOptions,
session: Session,
reservations: RegistrationReservations,
): {
agent: ReactLoopAgent
active: () => boolean
deactivated: Promise<void>
@@ -378,7 +386,7 @@ export class AgentLoop extends Service implements AgentFactory {
const driver = prepareReactLoopAgent(this.ctx, id, options, session)
const { agent } = driver
const scope: Scope = createScope(this.ctx, agent)
agent.ctx = scope.ctx.extend({ agent })
bindReactLoopAgentContext(agent, scope.ctx.extend({ agent }))
let active = true
let detachSession: (() => void) | undefined
@@ -422,19 +430,15 @@ export class AgentLoop extends Service implements AgentFactory {
const publish = (source: SessionStartSource): void => {
// Publication is one synchronous, rollback-covered sequence. Setup has
// already completed, so its scoped listeners observe both announcements.
detachSession = agent.ctx.sessions.enter(session)
detachAgent = this.ctx.agents.enter(agent)
detachSession = agent.ctx.sessions.enter(session, reservations.session)
detachAgent = this.ctx.agents.enter(agent, reservations.agent)
this.ctx.sessions.announce(session)
this.ctx.agents.announce(agent)
// Setup is over and both entries are live. Open the driving surface just
// before session-start so its listeners retain their supported ability to
// inject/queue, while setup itself can never drive an unpublished agent.
driver.enableDrive()
try {
agentEvents(this.ctx, agent).emit('agent/session-start', source)
} catch (error: unknown) {
this.ctx.logger.warn(`agent "${id}": agent/session-start listener threw: ${String(error)}`)
}
agentEvents(this.ctx, agent).emit('agent/session-start', source)
stop = driver.startDriver()
}
@@ -453,9 +457,13 @@ export class AgentLoop extends Service implements AgentFactory {
/** Publish a no-setup config agent synchronously. */
private start(
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
id: AgentId,
options: AgentOptions,
session: Session,
source: SessionStartSource,
reservations: RegistrationReservations,
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
const lifecycle = this.prepareLifecycle(id, options, session)
const lifecycle = this.prepareLifecycle(id, options, session, reservations)
try {
lifecycle.publish(source)
return { agent: lifecycle.agent, disposeAgent: lifecycle.disposeAgent }
@@ -485,9 +493,10 @@ export class AgentLoop extends Service implements AgentFactory {
*/
private async startOwned(
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
reservations: RegistrationReservations,
setup?: (agentCtx: Context) => Promise<void> | void,
): Promise<AgentHandle> {
const lifecycle = this.prepareLifecycle(id, options, session)
const lifecycle = this.prepareLifecycle(id, options, session, reservations)
try {
// The owner-disposal branch makes a never-settling setup unable to hold
// the transaction or its ID reservations forever. Promise.race installs

View File

@@ -7,7 +7,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { prepareReactLoopAgent } from '../src/agent.ts'
import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter) {
@@ -49,6 +49,34 @@ function send(agent: ReactLoopAgent, text: string) {
}
describe('ReactLoopAgent', () => {
it('owns immutable runtime bindings for id, options, session, and scoped context', 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.id).toBe('owned-bindings')
expect(agent.session).toBe(acceptedSession)
expect(agent.ctx).toBe(acceptedContext)
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()
})
it('send() throws after disposal', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
@@ -197,6 +225,23 @@ 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)
@@ -416,7 +461,7 @@ describe('ReactLoopAgent', () => {
expect(adapter.requests).toHaveLength(1)
expect(agent.status).toBe('idle')
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on running'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw'))
warn.mockRestore()
})
@@ -434,7 +479,7 @@ describe('ReactLoopAgent', () => {
expect(adapter.requests).toHaveLength(1)
expect(agent.status).toBe('idle')
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on idle'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw'))
warn.mockRestore()
})
})

View File

@@ -197,6 +197,35 @@ describe('agent scope lifecycle', () => {
await handle.dispose()
})
it('makes setup-time publication structurally impossible through public stores', 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'),
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()
},
})
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({
@@ -357,6 +386,33 @@ describe('agent scope lifecycle', () => {
await retry.dispose()
})
it('pairs session and agent announcements when agent creation aborts publication', async () => {
const ctx = await harness()
const lifecycle: string[] = []
ctx.on('session/created', (session) => { lifecycle.push(`session-created:${session.id}`) })
ctx.on('session/disposed', (session) => { lifecycle.push(`session-disposed:${session.id}`) })
ctx.on('agent/created', (agent) => {
lifecycle.push(`agent-created:${agent.id}`)
throw new Error('agent observer failed')
})
ctx.on('agent/disposed', (agent) => { lifecycle.push(`agent-disposed:${agent.id}`) })
await expect(ctx.agents.create({
agentId: AgentId('partial-agent'),
sessionId: SessionId('partial-session'),
agentOptions: { model: 'mock' },
})).rejects.toThrow('agent observer failed')
expect(lifecycle).toEqual([
'session-created:partial-session',
'agent-created:partial-agent',
'agent-disposed:partial-agent',
'session-disposed:partial-session',
])
expect(ctx.agents.get(AgentId('partial-agent'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('partial-session'))).toBeUndefined()
})
it('the synchronous config helper rolls back when publication throws', async () => {
const ctx = await harness()
const sessionsBefore = ctx.sessions.list().length