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:
10 files changed
+176
-24
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user