test(acp): cover the continuable drain ordering and its failure path
Pins that the bridge releases the Activation forest before its own sessions, and that a failed drain is reported without stranding that teardown. Reads the one teardown method structurally so the bridge keeps no dependency on the subagent seam.
This commit is contained in:
@@ -43,6 +43,16 @@ export const name = 'acp'
|
|||||||
/** The bridge creates and owns agents; every other concern is carried by the agent composition. */
|
/** The bridge creates and owns agents; every other concern is carried by the agent composition. */
|
||||||
export const inject = ['agents']
|
export const inject = ['agents']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single continuable-subagent teardown the bridge needs. Declared
|
||||||
|
* structurally so this package does not depend on the subagent seam for one
|
||||||
|
* shutdown hook; an absent service means nothing continuable was materialized.
|
||||||
|
*/
|
||||||
|
interface ContinuableDrain {
|
||||||
|
/** Close continuable admission, then dispose every live Activation child-first. */
|
||||||
|
drainContinuable(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
/** Preserve invalid-parameter detail in the SDK wire error message. */
|
/** Preserve invalid-parameter detail in the SDK wire error message. */
|
||||||
function invalidParams(detail: string): RequestError {
|
function invalidParams(detail: string): RequestError {
|
||||||
return RequestError.invalidParams(undefined, detail)
|
return RequestError.invalidParams(undefined, detail)
|
||||||
@@ -331,7 +341,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
|||||||
// Activations own descendant teardown. Drain that forest child-first
|
// Activations own descendant teardown. Drain that forest child-first
|
||||||
// BEFORE disposing the top-level agents, so no descendant is left holding
|
// BEFORE disposing the top-level agents, so no descendant is left holding
|
||||||
// a runtime its owner already released.
|
// a runtime its owner already released.
|
||||||
const subagents = ctx.get('subagents')
|
// Read the one teardown method structurally: the bridge needs no other
|
||||||
|
// part of the subagent seam, so it does not depend on that package.
|
||||||
|
const subagents = ctx.get('subagents') as ContinuableDrain | undefined
|
||||||
if (subagents !== undefined) {
|
if (subagents !== undefined) {
|
||||||
try {
|
try {
|
||||||
await subagents.drainContinuable()
|
await subagents.drainContinuable()
|
||||||
|
|||||||
@@ -25,6 +25,44 @@ describe('ACP connection ownership', () => {
|
|||||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('drains continuable subagents before disposing its own sessions', async () => {
|
||||||
|
harness = await makeBridgeHarness()
|
||||||
|
const order: string[] = []
|
||||||
|
// A continuable Activation outlives the turn that started it, so the bridge
|
||||||
|
// must release that forest before the agents whose runtime it depends on.
|
||||||
|
harness.ctx.provide('subagents', {
|
||||||
|
drainContinuable: () => {
|
||||||
|
order.push('drained')
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
} as never, true)
|
||||||
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||||
|
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||||
|
harness.ctx.on('agent/disposed', () => { order.push('agent disposed') })
|
||||||
|
|
||||||
|
await harness.acpFiber.dispose()
|
||||||
|
|
||||||
|
expect(order).toEqual(['drained', 'agent disposed'])
|
||||||
|
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[] = []
|
||||||
|
harness.ctx.logger.warn = (message: string) => { warnings.push(message) }
|
||||||
|
harness.ctx.provide('subagents', {
|
||||||
|
drainContinuable: () => Promise.reject(new Error('activation teardown failed')),
|
||||||
|
} as never, true)
|
||||||
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||||
|
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||||
|
|
||||||
|
await harness.acpFiber.dispose()
|
||||||
|
|
||||||
|
// A stuck descendant must not strand the bridge's own teardown.
|
||||||
|
expect(warnings.some(warning => warning.includes('continuable subagent teardown failed'))).toBe(true)
|
||||||
|
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('an ACP-only reload rejects new sessions before creating an orphan', async () => {
|
it('an ACP-only reload rejects new sessions before creating an orphan', async () => {
|
||||||
harness = await makeBridgeHarness()
|
harness = await makeBridgeHarness()
|
||||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||||
|
|||||||
Reference in New Issue
Block a user