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

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