fix(mode): guard suspended retry flush disposal
This commit is contained in:
@@ -234,6 +234,7 @@ export class ModesService extends Service {
|
|||||||
constructor(ctx: Context, config: ModeConfig = { modes: {} }) {
|
constructor(ctx: Context, config: ModeConfig = { modes: {} }) {
|
||||||
super(ctx, 'modes')
|
super(ctx, 'modes')
|
||||||
this.resolved = resolveConfig(config)
|
this.resolved = resolveConfig(config)
|
||||||
|
let disposed = false
|
||||||
|
|
||||||
// Boundary flushes ride the loop's interception seams, NOT the
|
// Boundary flushes ride the loop's interception seams, NOT the
|
||||||
// `session/event` feed: post-commit session observers are observe-only
|
// `session/event` feed: post-commit session observers are observe-only
|
||||||
@@ -273,7 +274,10 @@ export class ModesService extends Service {
|
|||||||
next,
|
next,
|
||||||
) => {
|
) => {
|
||||||
const decision = await 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 {
|
try {
|
||||||
this.onBoundary(agent.session, false)
|
this.onBoundary(agent.session, false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -281,6 +285,7 @@ export class ModesService extends Service {
|
|||||||
}
|
}
|
||||||
return decision
|
return decision
|
||||||
}, { prepend: true })
|
}, { prepend: true })
|
||||||
|
ctx.effect(() => () => { disposed = true }, 'dsh-mode: close boundary lifetime')
|
||||||
|
|
||||||
ctx.on('agent/created', (agent) => {
|
ctx.on('agent/created', (agent) => {
|
||||||
const seed = agent.options.mode
|
const seed = agent.options.mode
|
||||||
|
|||||||
@@ -874,6 +874,30 @@ describe('exit_plan_mode', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('HMR disposal', () => {
|
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 () => {
|
it('unregisters the service, listeners, prompt section, and stable exit tool with the plugin fiber', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(SystemPrompt)
|
await ctx.plugin(SystemPrompt)
|
||||||
|
|||||||
Reference in New Issue
Block a user