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:
@@ -159,6 +159,13 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
|
||||
reason: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`,
|
||||
})
|
||||
}
|
||||
// A NEW capture call invalidates any stale stage UNCONDITIONALLY, before
|
||||
// dispatch: only THIS call's own body may stage for this call's commit.
|
||||
// Without this, a stale entry orphaned by an outer short-circuited chain
|
||||
// could be promoted by a later call REUSING the same call id whose body
|
||||
// never staged (pre-execute-denied downstream, or invalid args throwing
|
||||
// before the stage) — reporting success for a value the model saw fail.
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL) pending = undefined
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
|
||||
@@ -171,13 +178,14 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
|
||||
this: unknown, exec: ToolExecution, _result: ToolExecutionResult, next: () => Promise<PostToolDecision>,
|
||||
): Promise<PostToolDecision> {
|
||||
if (exec.name !== STRUCTURED_OUTPUT_TOOL || pending === undefined) return next()
|
||||
/* v8 ignore start -- defensive second layer: the pre-execute clear above
|
||||
* already drops every stale stage before a new capture call dispatches,
|
||||
* so a call-id mismatch cannot be reached through the tool pipeline */
|
||||
if (pending.callId !== exec.callId) {
|
||||
// A stale stage from a different call: an outer listener short-circuited
|
||||
// that call's post-execute chain past this commit, so its verdict never
|
||||
// reached us and the value must never be promoted — drop it.
|
||||
pending = undefined
|
||||
return next()
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
const staged = pending
|
||||
try {
|
||||
const decision = await next()
|
||||
|
||||
Reference in New Issue
Block a user