fix(scope): close final ownership races

Drain idle injection flushes before agent teardown, snapshot approval and subagent provider inputs, and gate subagent lifecycle events on real child readiness. Align the RFCs and generated contracts with the hardened behavior.
This commit is contained in:
Tianyi Cui
2026-07-12 00:31:46 +08:00
parent e6c7522a75
commit 3529b3c166
43 changed files with 825 additions and 261 deletions
+5 -4
View File
@@ -242,11 +242,12 @@ describe('ReactLoopAgent', () => {
const dispose = prepared.startDriver()
// First dispose
dispose()
const firstDisposal = dispose()
expect(agent.status).toBe('disposed')
await firstDisposal
// Second dispose — idempotent, no throw
expect(() => { dispose() }).not.toThrow()
await expect(dispose()).resolves.toBeUndefined()
expect(agent.status).toBe('disposed')
})
@@ -347,10 +348,10 @@ describe('ReactLoopAgent', () => {
expect(agent.status).toBe('running')
const idle = agent.whenIdle() // queues an internal waiter (running)
dispose() // settles the waiter synchronously; whenIdle chains done
const disposal = dispose() // settles the waiter synchronously; whenIdle chains done
await idle
expect(agent.status).toBe('disposed')
await agent.done
await disposal
})
it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => {
@@ -440,4 +440,35 @@ describe('agent scope lifecycle', () => {
expect(ctx.sessions.get(SessionId('h1-s'))).toBeUndefined()
await unload
})
it('handle.dispose() awaits an idle-injection flush before unregistering or detaching', async () => {
const ctx = await harness()
const handle = await ctx.agents.create({
agentId: AgentId('idle-flush'),
sessionId: SessionId('idle-flush-s'),
agentOptions: { model: 'mock' },
})
const gate = Promise.withResolvers<undefined>()
let flushStarted = false
ctx.on('session/flush', (session) => {
if (session !== handle.agent.session) return
flushStarted = true
return gate.promise
})
handle.agent.inject(text('durable idle context'), { source: { kind: 'plugin', plugin: 'test' } })
expect(flushStarted).toBe(true)
let disposed = false
const disposal = handle.dispose().then(() => { disposed = true })
await new Promise(resolve => setTimeout(resolve, 0))
expect(disposed).toBe(false)
expect(ctx.agents.get(AgentId('idle-flush'))).toBe(handle.agent)
expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBe(handle.agent.session)
gate.resolve(undefined)
await disposal
expect(ctx.agents.get(AgentId('idle-flush'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBeUndefined()
})
})