fix(agent-loop): one quiescence boundary across owner unload and handle.dispose

Cordis effect disposers are single-shot but not await-idempotent: when the
owning fiber's unload invokes the raw wrapper first, a concurrent
handle.dispose() got an immediate undefined and resolved before teardown
finished — violating the driver's stated one-boundary contract (Codex
implementation-review finding). The teardown chain's FIRST-yielded (so
disposed-last) disposer now resolves a shared completion promise; the
handle path awaits it after the wrapper, so tool-finally, parent-teardown,
and owner-unload all observe the same fully-torn-down state. Regression
test: owner unload begins first, concurrent handle.dispose still awaits
unregistration + session detach.
This commit is contained in:
Tianyi Cui
2026-07-09 03:58:15 +08:00
parent e7b712453a
commit 513ba2716d
2 changed files with 35 additions and 1 deletions

View File

@@ -169,4 +169,25 @@ describe('agent scope lifecycle', () => {
agentEvents(ctx, agent).emit('agent/error', 2, 0, new Error('for a1'))
expect(heard).toEqual(['a1:2'])
})
it('handle.dispose() during owner unload still awaits true quiescence (shared boundary)', async () => {
const ctx = await harness()
let handle!: ReturnType<typeof ctx.agents.create>
const owner = await ctx.plugin(Object.assign((inner: Context) => {
handle = inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } })
}, { inject: ['agents'] }))
const teardownDone: string[] = []
ctx.on('agent/disposed', () => void teardownDone.push('unregistered'))
// Owner unload begins FIRST (invokes the raw cordis wrapper)…
const unload = owner.dispose()
// …and a concurrent handle.dispose() must not resolve before the chain
// actually finished (the raw wrapper returns undefined on a repeat call).
await handle.dispose()
expect(teardownDone).toContain('unregistered')
expect(ctx.agents.get(AgentId('h1'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('h1-s'))).toBeUndefined()
await unload
})
})