subagent-inprocess: stop the structured nudge loop once cancellation lands

A cancel that lands AFTER a child's clean turn end but before the nudge
continuation runs clears nothing — child.cancel() only kills queued or
running work — so the loop's turn-state check alone let a later
child.send() spend a fresh post-cancellation turn (and even capture a
structured result the caller had already abandoned). The loop condition
now also reads the run's own cancelled flag, re-evaluated after every
whenIdle(), through an accessor because closure assignments are
invisible to control-flow narrowing (an inline read lints always-true).
readResult keeps the outcome honest on this path: a completed-but-
uncaptured turn maps to aborted, not error, when a cancel is why the
nudging stopped — the cancel contract outranks the schema shortfall.
This commit is contained in:
Tianyi Cui
2026-07-06 21:03:40 +08:00
parent db4a39b024
commit 3987425547
2 changed files with 37 additions and 6 deletions

View File

@@ -171,6 +171,10 @@ export function startInProcessRun(
// `turn/end` is logged — settles as `aborted` (honoring the cancel contract)
// rather than falling through to the no-turn `error` mapping.
let cancelled = false
// An accessor, not an inline read: `cancelled` mutates from closures (the
// abort listener, run.cancel), which control-flow narrowing cannot see — an
// inline `!cancelled` in the nudge condition reads as always-true.
const isCancelled = (): boolean => cancelled
const requestCancel = (reason: string): void => {
cancelled = true
child.cancel(reason)
@@ -191,12 +195,17 @@ export function startInProcessRun(
// Nudge loop: a child that finished a turn CLEANLY without calling
// structured_output gets re-prompted, up to the backend-configured
// retry count. An errored/aborted turn is not nudged — its failure is
// the honest result. (This also covers a cancel: a cancelled turn ends
// `aborted`, and a pre-turn cancel leaves no `turn/end` at all, so
// neither reads `completed`.)
// the honest result (a cancelled turn ends `aborted`, and a pre-turn
// cancel leaves no `turn/end` at all, so neither reads `completed`).
// `!cancelled` closes the remaining window: a cancel landing AFTER a
// clean turn end clears nothing — `child.cancel()` only kills
// queued/running work — so without it the next `send` would spend a
// fresh post-cancellation turn; the condition re-evaluates after
// every `whenIdle()`, so a mid-nudge cancel stops the loop at the
// next boundary too.
let nudges = options.structuredNudgeRetries
while (
structured.captured(child) === undefined && nudges > 0
!isCancelled() && structured.captured(child) === undefined && nudges > 0
&& lastOwnTurnEnd(child, seedLength)?.data.reason.kind === 'completed'
) {
nudges -= 1
@@ -204,7 +213,7 @@ export function startInProcessRun(
await child.whenIdle()
}
}
return readResult(child, seedLength, cancelled, structured ? { captured: structured.captured(child) } : undefined)
return readResult(child, seedLength, isCancelled(), structured ? { captured: structured.captured(child) } : undefined)
} finally {
request.signal?.removeEventListener('abort', onAbort)
if (structured) {
@@ -266,7 +275,10 @@ function readResult(
: toStopReason(lastEnd?.data.reason)
if (structured) {
if (structured.captured) return { output, structured: structured.captured.value, stopReason }
if (stopReason === 'completed') return { output, stopReason: 'error' }
// No capture on a cleanly-completed turn: an ERROR when the run was left
// to finish (the nudges ran out), but ABORTED when a cancel is why the
// nudging stopped — the cancel contract outranks the schema shortfall.
if (stopReason === 'completed') return { output, stopReason: cancelled ? 'aborted' : 'error' }
}
return { output, stopReason }
}

View File

@@ -179,6 +179,25 @@ describe('in-process structured output', () => {
await run.dispose()
})
it('a cancel landing after a clean turn end stops the nudge loop: no post-cancellation turn is spent', async () => {
const { ctx, parent, adapter } = await setup([textResponse('prose, no capture')], { nudges: 3 })
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const child = ctx.agents.get(run.id)!
// Cancel synchronously inside the first turn's end recording — after the
// turn reads `completed`, before the nudge continuation resumes. The turn
// state alone cannot see this cancel (`child.cancel()` only clears
// queued/running work), so without the loop's own cancelled check the
// next send would spend a fresh child turn after the caller cancelled.
ctx.on('session/event', (session, event) => {
if (session === child.session && event.type === 'turn/end') run.cancel('cancelled between turn end and nudge')
})
const result = await run.result
expect(result.stopReason).toBe('aborted')
// Exactly one model request: the nudge turn never ran.
expect(adapter.requests.length).toBe(1)
await run.dispose()
})
it('rejects a schema outside the subset loud, before any child exists', async () => {
const { ctx, parent } = await setup([])
expect(() => ctx.subagents.start('spawn', structuredRequest(parent, {