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:
_Kerman
2026-07-26 20:50:02 +08:00
parent 338da9f2e0
commit 2a51ef85fb
6 changed files with 161 additions and 3 deletions

View File

@@ -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()
})
})