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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
Reference in New Issue
Block a user