fix(subagent): address codex review round 2

Round 1 traded one teardown ordering problem for another. The observer now
splits capture from emission, which satisfies both consumers at once:

- Terminal facts are captured while the child is still registered, so consumers
  that resolve it for the child's log and scope still work.
- The edge is emitted only after handle disposal settles, so a rejecting scoped
  cleanup is reported as a failed epoch instead of a successful one.

Also:

- Keep the Activation in the map until disposal settles. Removing it first let a
  racing followup() see no Activation and cold-resume into the still-registered
  agent, and let a concurrent forest drain skip a still-disposing child and
  release its parent first.
- Derive terminal telemetry from this epoch's event suffix rather than the whole
  session, so a cold resume whose prompt is blocked no longer reports the
  previous epoch's answer and turn reason.
- Cancel the ACP bridge's own prompts before awaiting the descendant drain: a
  drain can block on persistence, and the top-level agents must not keep running
  model and tool work for its whole duration.
This commit is contained in:
Dudu-0223
2026-08-02 12:51:08 +08:00
committed by Tianyi Cui
parent c485b6136d
commit cbaceb73a9
5 changed files with 153 additions and 37 deletions
+8 -4
View File
@@ -336,6 +336,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
closed = true
const records = [...sessions.values()]
sessions.clear()
// Stop the bridge's own work before any await: a descendant drain can block
// on persistence or scoped cleanup, and the top-level agents must not keep
// running model and tool calls for its whole duration.
for (const record of records) {
record.agent.cancel({ kind: 'user' })
settlePrompt(record, 'cancelled')
}
quiescing = (async () => {
// Continuable subagents outlive the turn that started them, and their
// Activations own descendant teardown. Drain that forest child-first
@@ -351,10 +358,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`)
}
}
await Promise.all(records.map(async (record) => {
settlePrompt(record, 'cancelled')
await record.dispose()
}))
await Promise.all(records.map(record => record.dispose()))
})()
return quiescing
}
+28
View File
@@ -46,6 +46,34 @@ describe('ACP connection ownership', () => {
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
})
it('cancels its own prompt before awaiting the descendant drain', async () => {
harness = await makeBridgeHarness({ script: ['hang'] })
const order: string[] = []
const release = Promise.withResolvers<undefined>()
harness.ctx.provide('subagents', {
drainContinuable: async () => {
order.push('drain started')
await release.promise
order.push('drain finished')
},
} as never)
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(SessionId(sessionId))!
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
await vi.waitFor(() => { expect(agent.status).toBe('running') })
harness.ctx.on('agent/cancel-requested', () => { order.push('parent cancelled') })
const disposal = harness.acpFiber.dispose()
// A drain can block on persistence, so the bridge's own turn must already be
// cancelled rather than running for its whole duration.
await vi.waitFor(() => { expect(order).toContain('drain started') })
expect(order).toEqual(['parent cancelled', 'drain started'])
release.resolve(undefined)
await disposal
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
})
it('reports a failed continuable drain and still disposes its sessions', async () => {
harness = await makeBridgeHarness()
const warnings: string[] = []
+28 -14
View File
@@ -108,17 +108,26 @@ export type ActivationState = 'running' | 'waiting' | 'settled'
* children emit the same start/end pair as one-shot runs.
*/
export interface ActivationObserver {
/** Publish the start edge once the epoch is resident. */
start(): void
/**
* Publish the terminal edge exactly once, pairing this epoch's {@link start}.
* Called only for a resident epoch: a failure before residency publishes no
* edge at all, because inventing one would report a lifecycle the child never
* had.
* @param child - the child agent whose final output the edge reports.
* Publish the start edge once the epoch is resident.
* @param child - the resident child agent, whose log suffix bounds this epoch.
*/
start(child: Agent): void
/**
* Snapshot the child-dependent terminal facts while the child is still
* registered, because handle disposal unregisters it and consumers resolve it
* to read the child's own log and scope.
* @param child - the quiescent child agent about to be released.
*/
capture(child: Agent): void
/**
* Publish the terminal edge exactly once, pairing this epoch's {@link start},
* after the disposal outcome is known. Called only for a resident epoch: a
* failure before residency publishes no edge, because inventing one would
* report a lifecycle the child never had.
* @param failure - the teardown or durability failure, or `undefined` on success.
*/
settle(child: Agent, failure: unknown): void
settle(failure: unknown): void
}
/** Hooks the manager needs from the owning service. */
@@ -585,7 +594,7 @@ export class SubagentContinuationManager {
})
// Resident: publish the start edge before any turn can run, so observers
// see this epoch before its first request.
observer.start()
observer.start(handle.agent)
this.watchSettlement(activation)
return activation
}
@@ -778,12 +787,10 @@ export class SubagentContinuationManager {
await activation.handle.agent.whenIdle()
const durability = await this.checkpoint(activation)
failure ??= durability
// Publish the terminal edge while the child is STILL registered:
// consumers resolve `ctx.agents.get(info.id)` in `subagent/end` to run
// in the child's own cwd and scope, which handle disposal removes.
activation.observer.settle(activation.handle.agent, failure)
// Capture the child-dependent edge data while the child is still live:
// handle disposal unregisters it, and consumers read its log and scope.
activation.observer.capture(activation.handle.agent)
} finally {
this.activations.delete(childId)
try {
await activation.handle.dispose()
} catch (error: unknown) {
@@ -793,9 +800,16 @@ export class SubagentContinuationManager {
{ cause: error },
)
} finally {
// Only now is the Activation gone: keeping the entry until disposal
// settles makes a racing delivery wait for release rather than
// cold-resume into the still-registered agent.
this.activations.delete(childId)
// Release ownership even on failure: a retained failed child would
// pin its ancestors in `waiting` forever.
this.releaseOwnership(childId)
// Emit once the disposal outcome is known, so a rejecting scoped
// cleanup cannot be reported as a successful epoch.
activation.observer.settle(failure)
}
}
if (failure !== undefined) throw failure
+28 -10
View File
@@ -363,22 +363,40 @@ export class SubagentService extends Service {
parent: Agent | undefined,
): ActivationObserver {
const identity = { runId: SubagentRunId(randomUUID()), provider, id: childId, local: true }
// A cold resume replays earlier turns, so this epoch's telemetry must come
// from the suffix it actually produced — never the whole session, which
// would report a previous epoch's answer when this one opened no turn.
let boundary = 0
// Assigned by `capture()`, which the disposal path always runs before
// `settle()`; a resident epoch therefore always has its facts by then.
let captured: { stopReason: SubagentResult['stopReason']; output?: ContentBlock[] } = {
stopReason: 'completed',
}
let settled = false
return {
start: (): void => {
start: (child: Agent): void => {
boundary = child.session.events.length
this.emitLifecycle('subagent/start', identity, parent)
},
settle: (child: Agent, failure: unknown): void => {
capture: (child: Agent): void => {
const own = child.session.events.slice(boundary)
const output = lastAssistantOutput(own)
captured = {
stopReason: epochStopReason(own),
...output === undefined ? {} : { output },
}
},
settle: (failure: unknown): void => {
// Exactly one terminal edge per epoch: host shutdown, manager unload,
// child release, and normal settlement all converge on one disposal.
/* v8 ignore next -- the memoized disposal already collapses those callers into a
* single settle(); this guard keeps the edge single if that memoization ever changes. */
if (settled) return
settled = true
const output = failure === undefined ? lastAssistantOutput(child) : undefined
const output = failure === undefined ? captured.output : undefined
this.emitLifecycle('subagent/end', {
...identity,
stopReason: failure === undefined ? childStopReason(child) : 'error',
stopReason: failure === undefined ? captured.stopReason : 'error',
...output === undefined ? {} : { lastAssistantMessage: output },
}, parent)
},
@@ -465,11 +483,11 @@ export class SubagentService extends Service {
* The child's own `turn/end` is authoritative: teardown succeeding says nothing
* about whether the model errored, hit its token ceiling, or was cancelled, so
* deriving the reason from disposal would report failed work as completed.
* @param child - the settling child agent whose log is read.
* @param events - this epoch's own event suffix.
* @returns its terminal stop reason; `completed` when no ordinary turn closed.
*/
function childStopReason(child: Agent): SubagentResult['stopReason'] {
const reason = findLastMessageTurnEnd(child.session.events)?.data.reason
function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopReason'] {
const reason = findLastMessageTurnEnd(events)?.data.reason
// No ordinary turn closed, so nothing failed either.
if (reason === undefined) return 'completed'
switch (reason.kind) {
@@ -494,11 +512,11 @@ function childStopReason(child: Agent): SubagentResult['stopReason'] {
/**
* The child's last assistant message content, for one Activation's terminal
* lifecycle edge. Absent when no assistant message reached the log.
* @param child - the settling child agent whose log is read.
* @param events - this epoch's own event suffix.
* @returns its final assistant content, or `undefined` when it produced none.
*/
function lastAssistantOutput(child: Agent): ContentBlock[] | undefined {
const message = child.session.events.findLast(
function lastAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined {
const message = events.findLast(
(event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message',
)
return message?.data.message.content
@@ -685,19 +685,71 @@ describe('continuable review regressions', () => {
expect(before).toBeGreaterThan(0)
})
it('publishes the terminal edge while the child agent is still resolvable', async () => {
const { ctx, parent } = await setup([textResponse('answer')])
const resolvable: boolean[] = []
// Consumers resolve the child in `subagent/end` to run in its own cwd.
ctx.on('subagent/end', (info) => {
resolvable.push(ctx.agents.get(info.id) !== undefined)
})
it('reports this epoch\'s own output, captured while the child was still live', async () => {
const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')])
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', (info) => { ends.push(info) })
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
// Handle disposal unregisters the child, so the edge's content must have
// been captured before that — an after-the-fact lookup would find nothing.
expect(ends[0]!.lastAssistantMessage).toEqual([{ type: 'text', text: 'first answer' }])
await vi.waitFor(() => { expect(resolvable).toHaveLength(1) })
expect(resolvable[0]).toBe(true)
// A cold resume is a new epoch: it must report its OWN answer, never the
// previous epoch's, which the replayed transcript still contains.
await followup(ctx, { kind: 'user' }, started.childId, message('again'))
await waitNoActivation(ctx, started.childId)
await vi.waitFor(() => { expect(ends).toHaveLength(2) })
expect(ends[1]!.lastAssistantMessage).toEqual([{ type: 'text', text: 'second answer' }])
})
it('reports a resumed epoch that opened no turn without the previous answer', async () => {
const { ctx, parent } = await setup([textResponse('first answer')])
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', (info) => { ends.push(info) })
// Block the resumed prompt so this epoch produces nothing of its own.
ctx.on('agent/prompt-submit', async (subject, _content, _source, _signal, next) => {
if (subject === parent) return next()
return { kind: 'block', reason: 'blocked by policy' }
})
await followup(ctx, { kind: 'user' }, started.childId, message('again'))
await waitNoActivation(ctx, started.childId)
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
// Reading the whole session would resurrect 'first answer' here.
expect(ends[0]!.lastAssistantMessage).toBeUndefined()
expect(ends[0]!.stopReason).toBe('completed')
})
it('reports handle-disposal failure on the terminal edge', async () => {
const { ctx, parent } = await setup([textResponse('answer')])
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', (info) => { ends.push(info) })
const started = await ctx.subagents.startContinuable(startSpec(parent))
const manager = (ctx.subagents as unknown as {
continuations: { activations: Map<SessionId, { handle: { dispose: () => Promise<void> } }> }
}).continuations
const activation = await vi.waitFor(() => {
const found = manager.activations.get(started.childId)
expect(found).toBeDefined()
return found!
})
const realDispose = activation.handle.dispose.bind(activation.handle)
activation.handle.dispose = async () => {
await realDispose()
throw new Error('scoped cleanup failed')
}
await expect(ctx.subagents.drainContinuable()).rejects.toThrow()
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
// Emitting before disposal would have reported this failed epoch as success.
expect(ends[0]!.stopReason).toBe('error')
})
it('cancels a running turn before the final durability checkpoint', async () => {