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