fix: honor the teardown order on owner unload; make the structured commit unconditional

Adversarial-review findings (own reviewer agent), each verified and pinned:

B1: agents.register() returned a wrapper lambda, so the factory composite's
yield could not identity-nest it — on OWNER unload the unregistration (and
agent/disposed) disposed as a concurrent sibling, firing mid-drain while
the final turn was still closing (pre-existing on master; this branch's
docs re-assert the order, so it must be true). register() now returns the
EXACT cordis effect disposer (the Scope.rawDispose move); the composite
nests it and owner unload runs stop/drain -> unregister -> detach -> scope
like every other path. Regression test pins turn-end before disposed
before detach on owner unload.

B2: the structured two-phase commit could promote a stale stage when a
later capture call REUSED the orphaned stage's call id with a body that
never staged (denied downstream, or invalid args throwing pre-stage). The
runtime's pre-execute listener now clears any stale stage unconditionally
when a new capture call enters the pipeline — only a call's own body can
stage for its commit; the call-id mismatch guard becomes a defensive
second layer. Repro test: blocked capture then same-id invalid call.

C1: an explicit empty toolFilter config now fails at plugin LOAD (the
check is self-contained) instead of killing every delegation at child
setup. C2: Scope.dispose/ScopeHost.dispose @returns state the single-shot
repeat-call semantics honestly.
This commit is contained in:
Tianyi Cui
2026-07-09 04:48:52 +08:00
parent 9ff8720da5
commit 547aacee2f
7 changed files with 122 additions and 10 deletions

View File

@@ -170,6 +170,40 @@ describe('agent scope lifecycle', () => {
expect(heard).toEqual(['a1:2'])
})
it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', 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('o1'), sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } })
}, { inject: ['agents'] }))
const { agent } = handle
const order: string[] = []
ctx.on('session/event', (_s, event) => {
if (event.type === 'turn/end') order.push('turn-end')
})
ctx.on('agent/disposed', () => {
order.push(`disposed(listed=${ctx.agents.get(AgentId('o1')) !== undefined})`)
order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`)
})
// Open a turn so the drain has real work: the loop must finish it BEFORE
// the registry entry goes away (the agent/disposed contract: "its fiber
// and any in-flight turn have been torn down"). Wait for the turn to be
// OPEN in the log — a dispose landing in the pre-step window would drop
// the queued prompt without ever opening a turn.
const turnOpen = new Promise<void>((resolve) => {
const off = ctx.on('session/event', (_s, event) => {
if (event.type === 'turn/start') { off(); resolve() }
})
})
agent.send(text('work'))
await turnOpen
await owner.dispose()
expect(order).toEqual(['turn-end', 'disposed(listed=false)', 'session-still-stored=true'])
expect(ctx.sessions.get(SessionId('o1-s'))).toBeUndefined()
})
it('handle.dispose() during owner unload still awaits true quiescence (shared boundary)', async () => {
const ctx = await harness()
let handle!: ReturnType<typeof ctx.agents.create>