fix(subagent): scope drains and soften final flush
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/acp/acp/README.md
|
||||
README.md: 9a48fdec3330cd364c1ab6de4c117b20af0f443f
|
||||
README.zh.md: 65732f41277a8760bfd2824aea12b0f240ae8025
|
||||
README.md: 583025e94d72c1ab03d282f8f4eb101c4e6f4740
|
||||
README.zh.md: 3a082b423c1e4ab7e236179a3f502cd450b4904c
|
||||
|
||||
@@ -35,7 +35,7 @@ Committed-message output intentionally trades token-by-token latency for a clean
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting their loop/session cleanup. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent.
|
||||
Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting every result before reporting any failure. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent.
|
||||
|
||||
## Running
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
## 生命周期
|
||||
|
||||
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待它们的循环/会话清理完成。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。
|
||||
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。
|
||||
|
||||
## 运行
|
||||
|
||||
|
||||
@@ -362,7 +362,14 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
await Promise.all(records.map(record => record.dispose()))
|
||||
const disposals = await Promise.allSettled(records.map(record => record.dispose()))
|
||||
const failures: unknown[] = []
|
||||
for (const result of disposals) {
|
||||
if (result.status === 'rejected') failures.push(result.reason as unknown)
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, `ACP agent teardown failed for ${failures.length} session(s)`)
|
||||
}
|
||||
})()
|
||||
return quiescing
|
||||
}
|
||||
|
||||
@@ -96,6 +96,52 @@ describe('ACP connection ownership', () => {
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('awaits every owned session disposal before reporting one failure', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
const create = harness.ctx.agents.create.bind(harness.ctx.agents)
|
||||
const releaseSecond = Promise.withResolvers<undefined>()
|
||||
const warnings: string[] = []
|
||||
let created = 0
|
||||
let secondStarted = false
|
||||
harness.ctx.logger.warn = (message: string) => { warnings.push(message) }
|
||||
const createSpy = vi.spyOn(harness.ctx.agents, 'create').mockImplementation(async (options) => {
|
||||
const handle = await create(options)
|
||||
const originalDispose = handle.dispose.bind(handle)
|
||||
if (created++ === 0) {
|
||||
handle.dispose = async () => {
|
||||
await originalDispose()
|
||||
throw new Error('first session cleanup failed')
|
||||
}
|
||||
} else {
|
||||
handle.dispose = async () => {
|
||||
secondStarted = true
|
||||
await releaseSecond.promise
|
||||
await originalDispose()
|
||||
}
|
||||
}
|
||||
return handle
|
||||
})
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const first = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const second = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
await harness.closeClientTransport()
|
||||
await vi.waitFor(() => { expect(secondStarted).toBe(true) })
|
||||
expect(warnings.some(warning => warning.includes('connection-close teardown failed'))).toBe(false)
|
||||
|
||||
releaseSecond.resolve(undefined)
|
||||
await vi.waitFor(() => {
|
||||
expect(warnings.some(warning => warning.includes('ACP agent teardown failed for 1 session(s)'))).toBe(true)
|
||||
expect(harness!.ctx.agents.get(SessionId(first.sessionId))).toBeUndefined()
|
||||
expect(harness!.ctx.agents.get(SessionId(second.sessionId))).toBeUndefined()
|
||||
})
|
||||
|
||||
createSpy.mockRestore()
|
||||
const disposed = harness
|
||||
harness = undefined
|
||||
await disposed.dispose().catch(() => undefined)
|
||||
})
|
||||
|
||||
it('an ACP-only reload rejects new sessions before creating an orphan', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
|
||||
Reference in New Issue
Block a user