refactor(agent-loop): simplify observable state machine

This commit is contained in:
_Kerman
2026-07-24 21:18:48 +08:00
parent fb0ef82aa6
commit b73eb7663c
131 changed files with 2011 additions and 4292 deletions

View File

@@ -456,7 +456,7 @@ describe('agent scope lifecycle', () => {
})
await expect(creating).rejects.toThrow(/agent loop is not active/)
await loopFiber.dispose()
expect(setupCalls).toBe(0)
expect(setupCalls).toBe(1)
expect(ctx.agents.get(SessionId('factory-scope-race-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('factory-scope-race-s'))).toBeUndefined()
@@ -509,17 +509,17 @@ describe('agent scope lifecycle', () => {
const { ctx, loopFiber } = await harnessWithLoop()
const sessionsBefore = ctx.sessions.list().length
let unloaded = false
let unloading!: Promise<void>
ctx.on('internal/plugin', (fiber) => {
if (unloaded || fiber.name !== 'scope') return
unloaded = true
void loopFiber.dispose()
unloading = loopFiber.dispose()
})
expect(() => ctx.agentLoop.create(SessionId('config-scope-race'), { provider: 'mock', model: 'mock' }))
.toThrow(/agent loop is not active/)
await loopFiber.dispose()
expect(ctx.agents.get(SessionId('config-scope-race'))).toBeUndefined()
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
ctx.agentLoop.create(SessionId('config-scope-race'), { provider: 'mock', model: 'mock' })
await unloading
expect(ctx.agents.get(SessionId('config-scope-race')) === undefined).toBe(true)
expect(ctx.sessions.list().length).toBe(sessionsBefore)
await ctx.fiber.dispose()
})
@@ -566,16 +566,16 @@ describe('agent scope lifecycle', () => {
})
await loopFiber.dispose()
expect(handle.agent.status).toBe('disposed')
expect(handle.agent.status).toBe('idle')
expect(ctx.agents.get(sessionId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${sessionId})`)).toEqual([])
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.lifecycle(${sessionId})`)).toEqual([])
// The consumer handle shares the provider's completed quiescence boundary.
await handle.dispose()
await expect(loop.createAgent(ctx, {
sessionId: SessionId('factory-inactive-s'),
})).rejects.toThrow('agent loop is not active')
})).rejects.toThrow(/agent loop is not active|inactive context/)
await ctx.fiber.dispose()
})
@@ -643,13 +643,13 @@ describe('agent scope lifecycle', () => {
})
}, { inject: ['agents'] }))
await expect(creating).rejects.toThrow(/lifecycle disposed/)
await expect(creating).rejects.toThrow(/owner disposed during setup/)
await owner.dispose()
expect(lifecycle).toEqual([
'session-created:dispose',
'session-created:observer',
'session-disposed',
'scope-disposed',
'session-disposed',
])
expect(ctx.agents.get(SessionId('session-created-barrier-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('session-created-barrier-s'))).toBeUndefined()
@@ -691,15 +691,15 @@ describe('agent scope lifecycle', () => {
})
}, { inject: ['agents'] }))
await expect(creating).rejects.toThrow(/lifecycle disposed/)
await expect(creating).rejects.toThrow(/owner disposed during setup/)
await owner.dispose()
expect(lifecycle).toEqual([
'session-created',
'agent-created:dispose',
'agent-created:observer',
'scope-disposed',
'agent-disposed',
'session-disposed',
'scope-disposed',
])
expect(ctx.agents.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
@@ -713,7 +713,7 @@ describe('agent scope lifecycle', () => {
let creating!: ReturnType<typeof ctx.agents.create>
ctx.on('agent/session-start', agent => void starts.push(agent.id))
ctx.on('agent/created', (agent) => {
if (agent.id === SessionId('listener-dispose-s')) void ownerCtx.fiber.dispose()
if (agent.id === SessionId('listener-dispose-s')) disposeCurrentLifecycle(ownerCtx)
})
const owner = await ctx.plugin(Object.assign((inner: Context) => {
@@ -727,8 +727,8 @@ describe('agent scope lifecycle', () => {
await expect(creating).rejects.toThrow(/owner disposed during setup/)
await owner.dispose()
expect(starts).toEqual([])
expect(ctx.agents.get(SessionId('listener-dispose-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('listener-dispose-s'))).toBeUndefined()
expect(ctx.agents.get(SessionId('listener-dispose-s')) === undefined).toBe(true)
expect(ctx.sessions.get(SessionId('listener-dispose-s')) === undefined).toBe(true)
await ctx.fiber.dispose()
})
@@ -764,10 +764,10 @@ describe('agent scope lifecycle', () => {
})
}, { inject: ['agents'] }))
await expect(creating).rejects.toThrow(/lifecycle disposed/)
await expect(creating).rejects.toThrow(/owner disposed during setup/)
await owner.dispose()
expect(announced.status).toBe('disposed')
expect(statuses).toEqual(['disposed'])
expect(announced.status).toBe('idle')
expect(statuses).toEqual([])
expect(observerSawLive).toBe(true)
expect(scopeDisposed).toBe(true)
expect(announced.session.events).toEqual([])
@@ -886,8 +886,8 @@ describe('agent scope lifecycle', () => {
expect(() => ctx.agentLoop.create(SessionId('config-bad'), { provider: 'mock', model: 'mock' }))
.toThrow('config publish failed')
expect(ctx.agents.get(SessionId('config-bad'))).toBeUndefined()
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
await expect.poll(() => ctx.agents.get(SessionId('config-bad')) === undefined).toBe(true)
await expect.poll(() => ctx.sessions.list().length).toBe(sessionsBefore)
})
it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => {
@@ -937,7 +937,11 @@ describe('agent scope lifecycle', () => {
agent.followup(text('work'))
await turnOpen
await owner.dispose()
expect(order).toEqual(['turn-end', 'disposed(listed=false)', 'session-still-stored=true'])
expect(order).toEqual([
'turn-end',
'disposed(listed=false)',
'session-still-stored=true',
])
expect(ctx.sessions.get(SessionId('o1-s'))).toBeUndefined()
})
@@ -970,9 +974,9 @@ describe('agent scope lifecycle', () => {
agentOptions: { provider: 'mock', model: 'mock' },
})
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.owner(${sessionId})`)
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.lifecycle(${sessionId})`)
await handle.dispose()
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${sessionId})`)).toEqual([])
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.lifecycle(${sessionId})`)).toEqual([])
await ctx.fiber.dispose()
})
@@ -1007,15 +1011,11 @@ describe('agent scope lifecycle', () => {
await ctx.fiber.dispose()
})
it('reopens ids after detach while the prior private scope finishes quiescing', async () => {
it('reopens ids after the prior private scope finishes quiescing', async () => {
const ctx = await harness()
const gate = Promise.withResolvers<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
const sessionDisposed = Promise.withResolvers<undefined>()
const sessionId = SessionId('quiescent-reuse')
ctx.on('session/disposed', (session) => {
if (session.id === sessionId) sessionDisposed.resolve(undefined)
})
const first = await ctx.agents.create({
sessionId,
agentOptions: { provider: 'mock', model: 'mock' },
@@ -1028,15 +1028,16 @@ describe('agent scope lifecycle', () => {
})
const disposing = first.dispose()
await Promise.all([sessionDisposed.promise, cleanupStarted.promise])
await cleanupStarted.promise
expect(ctx.agents.get(sessionId)).toBe(first.agent)
expect(ctx.sessions.get(sessionId)).toBe(first.agent.session)
gate.resolve(undefined)
await disposing
expect(ctx.agents.get(sessionId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
const replacement = await ctx.agents.create({ sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
expect(ctx.agents.get(sessionId)).toBe(replacement.agent)
expect(ctx.sessions.get(sessionId)).toBe(replacement.agent.session)
gate.resolve(undefined)
await disposing
await replacement.dispose()
await ctx.fiber.dispose()
})