fix(mode): guard suspended retry flush disposal

This commit is contained in:
Tianyi Cui
2026-07-20 23:37:25 +08:00
parent 7d697315f6
commit 01ac16b04b
2 changed files with 30 additions and 1 deletions
+6 -1
View File
@@ -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
+24
View File
@@ -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<true>()
const releaseRecovery = Promise.withResolvers<true>()
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)