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:
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user