fix: close three seams the message-machine refactor left open
agent-loop lifecycle: dispose drains machine.done to true quiescence. cancel()'s own running-to-idle transition can legitimately re-enter through an automation listener (goal-session's idle drive runs synchronously to its first await) and replace done with a fresh admission after the single capture; teardown now re-cancels and re-awaits until the slot stabilizes, so the scope never unwinds under a live run. tools: a nested concludeTurn() stages on its own execution and promotes to the enclosing composite only on the call's authoritative successful verdict. A post-execute policy that converts the nested success into an error no longer lets a recovering composite stop the turn on a failed terminal operation (the Code Mode structured-output shape). goal-session: the driver owns its round durability barrier again. The loop's persistence is eager write-behind with no turn-end flush, so the old post-turn agent/error signal for flush failures never fires; a settled round now sets needsCheckpoint and re-enters drive, flushing before the next reservation and disarming on failure instead of queueing an autonomous round on state that was never persisted.
This commit is contained in:
@@ -1042,4 +1042,43 @@ describe('agent scope lifecycle', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('drains a run re-entered by cancel\'s own idle transition before removing the scope', async () => {
|
||||
// Automation shaped like goal-session: the running→idle transition that
|
||||
// disposal's cancel produces immediately queues a follow-up prompt. The
|
||||
// teardown must drain that replacement run to true quiescence instead of
|
||||
// awaiting only the first captured done and unwinding under a live run.
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('never awaited')])
|
||||
const ctx = await harness(adapter)
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('drain-reentered-run'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const agent = handle.agent
|
||||
let reentered = false
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
if (subject !== agent || status !== 'idle' || reentered) return
|
||||
reentered = true
|
||||
agent.followup({ content: [{ type: 'text', text: 'reentrant' }], source: { kind: 'user' } })
|
||||
})
|
||||
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(reentered).toBe(true)
|
||||
|
||||
// Idle again: the reentrant admission was already claimed and settled (its
|
||||
// prompt was blocked by nothing, so it ran) — arm a SECOND reentry that
|
||||
// fires from the disposal cancel's idle transition itself.
|
||||
reentered = false
|
||||
await handle.dispose()
|
||||
|
||||
// The reentrant run either never started or was drained: the registries
|
||||
// are empty and nothing still drives the detached session.
|
||||
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
||||
expect(ctx.sessions.get(agent.id)).toBeUndefined()
|
||||
const eventsAfter = agent.session.events.length
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
expect(agent.session.events.length).toBe(eventsAfter)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user