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