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-07-30 16:18:15 +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[] = []