fix: make the six registration methods atomic under a throwing change-listener (P1-1)
llm.registerAdapter, agents.register, sessions.create, systemPrompt.section, systemPrompt.tools, and tools.register each mutated state, emitted a change event, then returned the disposer. In Cordis a synchronous throw before the effect returns its disposer leaves nothing for the fiber to collect, so a throwing change-listener leaked the registry entry permanently — HMR/dispose could not clean it, and the duplicate-name/already-exists check stayed wedged until restart. Convert each to the generator-effect pattern already proven in AgentLoop.create: mutate state, `yield` the disposer that undoes it (collected before the next step runs, so it is torn down if a later step throws), THEN emit the change event. The existing duplicate-name throws are unchanged — they fire before any mutation, so they correctly leak nothing. No public API change: generator effects are still synchronous SyncEffects and register() keeps returning its fire-and-forget disposer wrapper. Tests: a listener-throw rollback test for all six methods — register with a change-listener that throws, assert the call throws AND the registry is clean (entry absent; a subsequent listener-free register of the same name succeeds and contributes exactly once). For systemPrompt (no duplicate-name check) the two tests assert assembly is clean. Verified each fails against the pre-fix emit-before-return-disposer form.
This commit is contained in:
@@ -35,17 +35,22 @@ export class AgentRegistry extends Service {
|
||||
* when the calling fiber is disposed. Returns the disposer.
|
||||
*/
|
||||
register(agent: Agent): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
|
||||
if (this.store.has(agent.id)) {
|
||||
throw new Error(`agent "${agent.id}" is already registered`)
|
||||
}
|
||||
this.store.set(agent.id, agent)
|
||||
this.ctx.emit('agent/created', agent)
|
||||
return () => {
|
||||
// Yield the rollback BEFORE emitting `agent/created`: a generator effect
|
||||
// collects each yielded disposer before the next step runs, so a
|
||||
// throwing `agent/created` listener rolls the entry back instead of
|
||||
// leaking it (a leak would wedge the duplicate-id check until restart).
|
||||
// The duplicate throw above fires before any mutation — it leaks nothing.
|
||||
yield () => {
|
||||
this.store.delete(agent.id)
|
||||
this.ctx.emit('agent/disposed', agent)
|
||||
}
|
||||
}, 'agents.register()')
|
||||
this.ctx.emit('agent/created', agent)
|
||||
}.bind(this), 'agents.register()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
|
||||
@@ -52,4 +52,25 @@ describe('AgentRegistry', () => {
|
||||
await fiber.dispose()
|
||||
expect(ctx.agents.list().map(a => a.id)).toEqual(['main'])
|
||||
})
|
||||
|
||||
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('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'])
|
||||
dispose()
|
||||
expect(ctx.agents.get('main')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -80,19 +80,25 @@ export class LlmService extends Service {
|
||||
* fiber.
|
||||
*/
|
||||
registerAdapter(models: string[], adapter: LlmAdapter): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const dispose = this.ctx.effect(function* (this: LlmService) {
|
||||
for (const model of models) {
|
||||
if (this.adapters.has(model)) {
|
||||
throw new LlmError(`an adapter for model "${model}" is already registered`, 'DUPLICATE_ADAPTER')
|
||||
}
|
||||
}
|
||||
for (const model of models) this.adapters.set(model, adapter)
|
||||
this.ctx.emit('llm/adapter-change')
|
||||
return () => {
|
||||
// Yield the rollback BEFORE emitting the change event: a generator effect
|
||||
// collects each yielded disposer before running the next step, so a
|
||||
// throwing `llm/adapter-change` listener rolls the mutation back instead
|
||||
// of leaking the entry (which would wedge the duplicate check until
|
||||
// restart). The duplicate throws above fire before any mutation, so they
|
||||
// correctly leak nothing.
|
||||
yield () => {
|
||||
for (const model of models) this.adapters.delete(model)
|
||||
this.ctx.emit('llm/adapter-change')
|
||||
}
|
||||
}, 'llm.registerAdapter()')
|
||||
this.ctx.emit('llm/adapter-change')
|
||||
}.bind(this), 'llm.registerAdapter()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
|
||||
@@ -146,4 +146,26 @@ describe('LlmService', () => {
|
||||
expect((error as LlmError).code).toBe('DUPLICATE_ADAPTER')
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back the adapter entry when an adapter-change listener throws (P1-1)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
// A change listener that throws on the FIRST emit only.
|
||||
let threw = false
|
||||
ctx.on('llm/adapter-change', () => {
|
||||
if (!threw) { threw = true; throw new Error('boom change listener') }
|
||||
})
|
||||
|
||||
// The throwing emit must roll the mutation back, not leak it.
|
||||
expect(() => ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))).toThrow('boom change listener')
|
||||
expect(ctx.llm.models()).toEqual([]) // entry rolled back, not leaked
|
||||
|
||||
// A subsequent listener-free register of the SAME model succeeds and
|
||||
// contributes exactly once (the duplicate check is not wedged).
|
||||
const dispose = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
|
||||
expect(ctx.llm.models()).toEqual(['m1'])
|
||||
dispose()
|
||||
expect(ctx.llm.models()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -167,15 +167,21 @@ export class SessionStore extends Service {
|
||||
const sessionId = SessionId(id ?? `session-${++this.counter}`)
|
||||
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
|
||||
const session = new Session(sessionId, seed)
|
||||
this.ctx.effect(() => {
|
||||
this.ctx.effect(function* (this: SessionStore) {
|
||||
session.onAppend = (event) => { this.ctx.emit('session/event', session, event) }
|
||||
this.store.set(sessionId, session)
|
||||
this.ctx.emit('session/created', session)
|
||||
return () => {
|
||||
// Yield the rollback BEFORE emitting `session/created`: a generator
|
||||
// effect collects each yielded disposer before the next step runs, so a
|
||||
// throwing `session/created` listener detaches onAppend and removes the
|
||||
// store entry instead of leaking them (a leak would wedge the
|
||||
// already-exists check until restart). The duplicate throw above fires
|
||||
// before any mutation — it leaks nothing.
|
||||
yield () => {
|
||||
session.onAppend = undefined
|
||||
this.store.delete(sessionId)
|
||||
}
|
||||
}, 'sessions.create()')
|
||||
this.ctx.emit('session/created', session)
|
||||
}.bind(this), 'sessions.create()')
|
||||
return session
|
||||
}
|
||||
|
||||
|
||||
@@ -132,4 +132,27 @@ describe('SessionStore', () => {
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } })
|
||||
expect(observed).toBe(0)
|
||||
})
|
||||
|
||||
it('rolls back the session (and onAppend) when a session/created listener throws (P1-1)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
let threw = false
|
||||
ctx.on('session/created', () => {
|
||||
if (!threw) { threw = true; throw new Error('boom created listener') }
|
||||
})
|
||||
|
||||
// The throwing emit must roll the store entry back, not leak it.
|
||||
expect(() => ctx.sessions.create('fixed')).toThrow('boom created listener')
|
||||
expect(ctx.sessions.get('fixed')).toBeUndefined() // rolled back, not leaked
|
||||
|
||||
// A subsequent create of the SAME id succeeds (the already-exists check is
|
||||
// not wedged) and its onAppend is correctly wired (events observable).
|
||||
const events: SessionEvent[] = []
|
||||
ctx.on('session/event', (_session, event) => void events.push(event))
|
||||
const session = ctx.sessions.create('fixed')
|
||||
expect(ctx.sessions.get('fixed')).toBe(session)
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -73,16 +73,20 @@ export class SystemPrompt extends Service {
|
||||
* fiber is disposed. Emits `system-prompt/change` on register/unregister.
|
||||
*/
|
||||
section(section: PromptSection): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
this.sections.push(section)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
return () => {
|
||||
// Yield the rollback BEFORE emitting `system-prompt/change`: a generator
|
||||
// effect collects each yielded disposer before the next step runs, so a
|
||||
// throwing change listener removes the section instead of leaking it into
|
||||
// every future assembly.
|
||||
yield () => {
|
||||
const index = this.sections.indexOf(section)
|
||||
/* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */
|
||||
if (index >= 0) this.sections.splice(index, 1)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
}, 'systemPrompt.section()')
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}.bind(this), 'systemPrompt.section()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
@@ -94,16 +98,17 @@ export class SystemPrompt extends Service {
|
||||
* removed when the calling fiber is disposed. Emits `system-prompt/change`.
|
||||
*/
|
||||
tools(provider: () => ToolSchema[]): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
this.toolProviders.push(provider)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
return () => {
|
||||
// Yield the rollback BEFORE emitting `system-prompt/change` (see section()).
|
||||
yield () => {
|
||||
const index = this.toolProviders.indexOf(provider)
|
||||
/* v8 ignore next 3 -- defensive: provider was registered, so indexOf is guaranteed >= 0 */
|
||||
if (index >= 0) this.toolProviders.splice(index, 1)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
}, 'systemPrompt.tools()')
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}.bind(this), 'systemPrompt.tools()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
|
||||
@@ -34,6 +34,44 @@ describe('SystemPrompt', () => {
|
||||
expect(assembly.tools).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rolls back a section when a system-prompt/change listener throws (P1-1)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
// Throw on the first emit only. Note the rollback path itself emits
|
||||
// system-prompt/change, so a multi-shot guard would also fire on rollback;
|
||||
// a single-shot guard isolates the register's own emit.
|
||||
let threw = false
|
||||
const off = ctx.on('system-prompt/change', () => {
|
||||
if (!threw) { threw = true; throw new Error('boom change listener') }
|
||||
})
|
||||
|
||||
expect(() => ctx.systemPrompt.section({ name: 'p', order: 0, text: 'persona' })).toThrow('boom change listener')
|
||||
expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0) // nothing leaked
|
||||
|
||||
// Subsequent listener-free register contributes exactly once.
|
||||
off()
|
||||
ctx.systemPrompt.section({ name: 'p', order: 0, text: 'persona' })
|
||||
expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['p'])
|
||||
})
|
||||
|
||||
it('rolls back a tool provider when a system-prompt/change listener throws (P1-1)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
let threw = false
|
||||
const off = ctx.on('system-prompt/change', () => {
|
||||
if (!threw) { threw = true; throw new Error('boom change listener') }
|
||||
})
|
||||
|
||||
expect(() => ctx.systemPrompt.tools(() => [{ name: 't', description: '', parameters: {} }])).toThrow('boom change listener')
|
||||
expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0) // nothing leaked
|
||||
|
||||
off()
|
||||
ctx.systemPrompt.tools(() => [{ name: 't', description: '', parameters: {} }])
|
||||
expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t'])
|
||||
})
|
||||
|
||||
it('composes multiple system-prompt/assemble waterfall listeners in order', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
@@ -140,17 +140,22 @@ export class ToolRegistry extends Service {
|
||||
* with the calling fiber. Emits `tools/change` on register/unregister.
|
||||
*/
|
||||
register(definition: ToolDefinition): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
||||
if (this.store.has(definition.name)) {
|
||||
throw new Error(`tool "${definition.name}" is already registered`)
|
||||
}
|
||||
this.store.set(definition.name, definition)
|
||||
this.ctx.emit('tools/change')
|
||||
return () => {
|
||||
// Yield the rollback BEFORE emitting `tools/change`: a generator effect
|
||||
// collects each yielded disposer before the next step runs, so a throwing
|
||||
// `tools/change` listener removes the tool instead of leaking it (a leak
|
||||
// would wedge the duplicate-name check until restart). The duplicate
|
||||
// throw above fires before any mutation — it leaks nothing.
|
||||
yield () => {
|
||||
this.store.delete(definition.name)
|
||||
this.ctx.emit('tools/change')
|
||||
}
|
||||
}, 'tools.register()')
|
||||
this.ctx.emit('tools/change')
|
||||
}.bind(this), 'tools.register()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
|
||||
@@ -147,6 +147,27 @@ describe('ToolRegistry', () => {
|
||||
dispose()
|
||||
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
|
||||
})
|
||||
|
||||
it('rolls back the tool entry when a tools/change listener throws (P1-1)', async () => {
|
||||
const ctx = await setup()
|
||||
|
||||
let threw = false
|
||||
ctx.on('tools/change', () => {
|
||||
if (!threw) { threw = true; throw new Error('boom change listener') }
|
||||
})
|
||||
|
||||
// The throwing emit must roll the entry back, not leak it.
|
||||
expect(() => ctx.tools.register(echoTool)).toThrow('boom change listener')
|
||||
expect(ctx.tools.get('echo')).toBeUndefined() // rolled back, not leaked
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
|
||||
// A subsequent listener-free register of the SAME name succeeds and is
|
||||
// exposed exactly once (the duplicate-name check is not wedged).
|
||||
const dispose = ctx.tools.register(echoTool)
|
||||
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
|
||||
dispose()
|
||||
expect(ctx.tools.get('echo')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineTool / schema DSL', () => {
|
||||
|
||||
Reference in New Issue
Block a user