feat(subagent): deliver continuable child settlement to parents
A continuable child that stopped without reporting — an error, a token ceiling, cancellation, teardown — left its parent nothing to act on. The continuation manager now delivers an unconditional settlement notice to the durable direct parent before releasing ownership, folding consumed work (foldConsumedWork supersedes findLastMessageTurnEnd) so a claimed-but-unrun prompt reads as aborted rather than completed, waking an idle parent, steering a busy one, and never waking a closing tree.
This commit is contained in:
@@ -31,6 +31,8 @@ const FAILED_CHECKPOINT_TURN = 3
|
||||
/** Fail the child checkpoint and stabilize the authored follow-up failure ordering. */
|
||||
export function apply(ctx: Context): void {
|
||||
const followupsAccepted = Promise.withResolvers<undefined>()
|
||||
const parentTurnClosed = Promise.withResolvers<undefined>()
|
||||
let parentClosed = false
|
||||
const publishedFailure = process.env.DSH_SUBAGENT_PUBLISHED_FAILURE === '1'
|
||||
const persistence = ctx.sessionPersistence
|
||||
const load = persistence.load.bind(persistence)
|
||||
@@ -62,8 +64,22 @@ export function apply(ctx: Context): void {
|
||||
agents.create = create
|
||||
persistence.load = load
|
||||
followupsAccepted.resolve(undefined)
|
||||
parentTurnClosed.resolve(undefined)
|
||||
}, 'subagent snapshot ordering')
|
||||
|
||||
// The manager's settlement notice races whatever the parent is doing when the
|
||||
// child's Activation ends, and this transcript pins it as the parent's own
|
||||
// later turn. Hold the child's steps until the parent's spawn turn closes, so
|
||||
// the notice can only arrive at an idle parent. The parent's turn never awaits
|
||||
// child model work — its own fences need inbox acceptance only — so the child
|
||||
// cannot deadlock it.
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session.header.parentSession !== undefined || event.type !== 'turn/end') return
|
||||
if (event.data.turn !== 1) return
|
||||
parentClosed = true
|
||||
parentTurnClosed.resolve(undefined)
|
||||
})
|
||||
|
||||
// Remap the placeholder child id in a follow-up to the live child. The child
|
||||
// id the model "knows" is authored into the transcript, while the running
|
||||
// child is minted with a random id, so without this the follow-ups would
|
||||
@@ -91,7 +107,12 @@ export function apply(ctx: Context): void {
|
||||
if (accepted >= 3) followupsAccepted.resolve(undefined)
|
||||
})
|
||||
ctx.on('agent/pre-step', async ({ agent }, next) => {
|
||||
if (agent.session.header.parentSession !== undefined) await followupsAccepted.promise
|
||||
if (agent.session.header.parentSession === undefined) return next()
|
||||
await followupsAccepted.promise
|
||||
// The published-failure variant's child never reaches a step (its follow-up
|
||||
// throws), and its parent turn awaits that child, so only the continuable
|
||||
// scenario takes the settlement fence.
|
||||
if (!publishedFailure && !parentClosed) await parentTurnClosed.promise
|
||||
return next()
|
||||
})
|
||||
|
||||
|
||||
41
examples/acp-agent/tests/fixtures/subagent-report-fence.ts
vendored
Normal file
41
examples/acp-agent/tests/fixtures/subagent-report-fence.ts
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Loader fixture that holds the report child until its parent's spawn turn ends.
|
||||
* @module subagent-report-fence
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-agent-loop'
|
||||
|
||||
/** Fixture plugin name. */
|
||||
export const name = 'subagent-report-fence'
|
||||
|
||||
/**
|
||||
* Keep replay scheduling from folding settlement into the parent's first turn.
|
||||
* @param ctx - assembled ACP-agent context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const childReady = Promise.withResolvers<undefined>()
|
||||
const parentStopped = Promise.withResolvers<undefined>()
|
||||
let hasStopped = false
|
||||
|
||||
ctx.effect(() => {
|
||||
const disposeSession = ctx.root.on('session/event', (session, event) => {
|
||||
if (session.header.parentSession !== undefined || event.type !== 'turn/end' || event.data.turn !== 1) return
|
||||
hasStopped = true
|
||||
parentStopped.resolve(undefined)
|
||||
})
|
||||
const disposeStep = ctx.root.on('agent/pre-step', async ({ agent, turn, step }, next) => {
|
||||
if (agent.session.header.parentSession !== undefined) {
|
||||
childReady.resolve(undefined)
|
||||
if (!hasStopped) await parentStopped.promise
|
||||
} else if (turn === 1 && step === 2) {
|
||||
await childReady.promise
|
||||
}
|
||||
return next()
|
||||
})
|
||||
return () => {
|
||||
disposeStep()
|
||||
disposeSession()
|
||||
}
|
||||
}, 'subagent-report-fence.listeners')
|
||||
}
|
||||
Reference in New Issue
Block a user