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:
@@ -530,4 +530,42 @@ describe('in-process structured output', () => {
|
||||
expect(valid.isError).toBeFalsy()
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a later capture call REUSING a stale stage\'s call id never promotes it (unconditional commit safety)', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// Orphan a stage: an outer short-circuiting post-execute BLOCK on the
|
||||
// first capture (its chain never reaches the commit listener).
|
||||
let blocks = 1
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
|
||||
blocks -= 1
|
||||
return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] })
|
||||
}
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
await run.result
|
||||
// A SECOND capture call with the SAME call id whose body never stages
|
||||
// (invalid args throw before the stage): the stale value must not ride
|
||||
// its acceptance.
|
||||
const reused = await ctx.tools.execute({
|
||||
callId: 'c1' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 'not-a-number' },
|
||||
agent: child,
|
||||
})
|
||||
expect(reused.isError).toBe(true)
|
||||
// Nothing was ever committed: a fresh valid call is still required.
|
||||
const valid = await ctx.tools.execute({
|
||||
callId: 'c1' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 5 },
|
||||
agent: child,
|
||||
})
|
||||
expect(valid.isError).toBeFalsy()
|
||||
await run.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user