diff --git a/packages/mode/mode/src/index.ts b/packages/mode/mode/src/index.ts index ae1f8e3bc3..7daa02f073 100644 --- a/packages/mode/mode/src/index.ts +++ b/packages/mode/mode/src/index.ts @@ -234,6 +234,7 @@ export class ModesService extends Service { constructor(ctx: Context, config: ModeConfig = { modes: {} }) { super(ctx, 'modes') this.resolved = resolveConfig(config) + let disposed = false // Boundary flushes ride the loop's interception seams, NOT the // `session/event` feed: post-commit session observers are observe-only @@ -273,7 +274,10 @@ export class ModesService extends Service { next, ) => { const decision = await next() - if (decision.action !== 'retry') return decision + // A waterfall can capture this wrapper before Cordis unregisters it. + // Do not let that stale continuation mutate the session after its + // owning plugin fiber has been disposed. + if (disposed || decision.action !== 'retry') return decision try { this.onBoundary(agent.session, false) } catch (error) { @@ -281,6 +285,7 @@ export class ModesService extends Service { } return decision }, { prepend: true }) + ctx.effect(() => () => { disposed = true }, 'dsh-mode: close boundary lifetime') ctx.on('agent/created', (agent) => { const seed = agent.options.mode diff --git a/packages/mode/mode/tests/mode.spec.ts b/packages/mode/mode/tests/mode.spec.ts index 0301e30e63..8fd5064abb 100644 --- a/packages/mode/mode/tests/mode.spec.ts +++ b/packages/mode/mode/tests/mode.spec.ts @@ -874,6 +874,30 @@ describe('exit_plan_mode', () => { }) describe('HMR disposal', () => { + it('does not flush a retry boundary that resumes after plugin disposal', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + const fiber = await ctx.plugin(ModesService, PLAN_CONFIG) + const agent = agentWithSession('disposed-in-flight-recovery') + const recoveryEntered = Promise.withResolvers() + const releaseRecovery = Promise.withResolvers() + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, _next) => { + recoveryEntered.resolve(true) + await releaseRecovery.promise + return { action: 'retry' } + }) + ctx.modes.set(agent, PLAN_MODE) + + const recovery = recoveryBoundary(ctx, agent, { action: 'fail' }) + await recoveryEntered.promise + await fiber.dispose() + releaseRecovery.resolve(true) + + expect(await recovery).toEqual({ action: 'retry' }) + expect(agent.session.events.some(event => event.type === 'mode/set')).toBe(false) + }) + it('unregisters the service, listeners, prompt section, and stable exit tool with the plugin fiber', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt)