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:
Tianyi Cui
2026-06-15 00:25:17 +08:00
parent 37576ade6a
commit 2df41ee1d3
10 changed files with 176 additions and 24 deletions

View File

@@ -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
}

View File

@@ -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)
})
})