fix(host): cancel retired metric capacity lookups (round 7)

This commit is contained in:
Hypatia May
2026-07-28 17:03:07 +08:00
parent 032cd2f72d
commit 344667cbf0
4 changed files with 287 additions and 29 deletions
@@ -65,7 +65,10 @@ class CatalogAdapter extends LlmAdapter {
}
class DeferredCatalogAdapter extends CatalogAdapter {
readonly pending: PromiseWithResolvers<LlmResolvedModelInfo>[] = []
readonly pending: {
result: PromiseWithResolvers<LlmResolvedModelInfo>
signal: AbortSignal | undefined
}[] = []
constructor() {
super('Deferred', [
@@ -73,16 +76,20 @@ class DeferredCatalogAdapter extends CatalogAdapter {
])
}
override resolveModel(_provider: string, _model: string): Promise<LlmResolvedModelInfo> {
override resolveModel(
_provider: string,
_model: string,
signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
const result = Promise.withResolvers<LlmResolvedModelInfo>()
this.pending.push(result)
this.pending.push({ result, signal })
return result.promise
}
resolve(index: number, contextWindow: number): void {
const pending = this.pending[index]
if (pending === undefined) throw new Error(`no pending resolution at index ${String(index)}`)
pending.resolve({
pending.result.resolve({
provider: 'deferred',
id: 'lifecycle-model',
name: 'Lifecycle model',
@@ -563,6 +570,140 @@ describe('Web session model selection', () => {
await ctx.fiber.dispose()
})
it('aborts pending capacity during adapter UNLOADING and refreshes after settlement', async () => {
const ctx = await hostContext()
const retiredAdapter = new DeferredCatalogAdapter()
const releaseUnload = Promise.withResolvers<undefined>()
const releaseCancellationWait = Promise.withResolvers<undefined>()
const abortObserved = Promise.withResolvers<undefined>()
const retiredFiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.llm.registerAdapter(['deferred'], retiredAdapter)
inner.effect(
() => () => releaseUnload.promise,
'test: hold adapter unload',
)
inner.effect(() => () => {
const signal = retiredAdapter.pending[0]?.signal
if (signal === undefined) throw new Error('pending capacity signal missing')
const cancellation = new Promise<void>((resolve) => {
const finish = () => {
abortObserved.resolve(undefined)
resolve()
}
if (signal.aborted) finish()
else signal.addEventListener('abort', finish, { once: true })
})
return Promise.race([cancellation, releaseCancellationWait.promise])
}, 'test: await capacity cancellation')
}, { inject: ['llm'] }))
const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-adapter-unloading'))
const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
const api = createApiProxy(ctx, {
provider: 'deepseek',
model: 'deepseek-chat',
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
const resolveModelInfo = vi.spyOn(ctx.llm, 'resolveModelInfo')
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(retiredAdapter.pending).toHaveLength(1) })
expect(resolveModelInfo).toHaveBeenCalledOnce()
const listSessions = vi.spyOn(ctx.sessions, 'list')
listSessions.mockClear()
const pendingFrame = iterator.next()
const disposing = retiredFiber.dispose()
try {
await vi.waitFor(() => {
expect(retiredAdapter.pending[0]?.signal?.aborted).toBe(true)
})
await abortObserved.promise
expect(retiredFiber.state).toBe(FiberState.UNLOADING)
retiredAdapter.resolve(0, 64_000)
await settleCapacityCompletion()
expect(resolveModelInfo).toHaveBeenCalledOnce()
expect(listSessions).not.toHaveBeenCalled()
const outcome = await Promise.race([
pendingFrame.then(() => 'frame' as const),
new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }),
])
expect(outcome).toBe('idle')
} finally {
releaseCancellationWait.resolve(undefined)
releaseUnload.resolve(undefined)
await disposing
}
expect(retiredFiber.state).toBe(FiberState.DISPOSED)
const settledFrame = await pendingFrame
if (settledFrame.done || settledFrame.value.payload.type !== 'session/metrics') {
throw new Error('expected settled metrics refresh')
}
expect(settledFrame.value.payload.metrics.contextWindow).toBeUndefined()
await settleCapacityCompletion()
expect(resolveModelInfo).toHaveBeenCalledTimes(2)
const replacementAdapter = new DeferredCatalogAdapter()
const replacementFiber = await installDeferredAdapter(ctx, replacementAdapter)
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(replacementAdapter.pending).toHaveLength(1) })
expect(resolveModelInfo).toHaveBeenCalledTimes(3)
replacementAdapter.resolve(0, 128_000)
await settleCapacityCompletion()
expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
controller.abort()
await iterator.return?.()
detachAgent()
lifecycle.detach()
await replacementFiber.dispose()
await ctx.fiber.dispose()
})
it('aborts pending capacity when the API proxy fiber is disposed', async () => {
const ctx = await hostContext()
const deferred = new DeferredCatalogAdapter()
ctx.llm.registerAdapter(['deferred'], deferred)
const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-api-proxy-teardown'))
const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
const proxy = Promise.withResolvers<ReturnType<typeof createApiProxy>>()
const proxyFiber = await ctx.plugin(Object.assign((inner: Context) => {
proxy.resolve(createApiProxy(inner, {
provider: 'deepseek',
model: 'deepseek-chat',
cwd: '/tmp',
workspaceRoot: '/tmp',
}))
}, { inject: ['agents', 'sessions', 'userInteraction'] }))
const api = await proxy.promise
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
expect(deferred.pending[0]?.signal?.aborted).toBe(false)
await proxyFiber.dispose()
expect(deferred.pending[0]?.signal?.aborted).toBe(true)
deferred.resolve(0, 64_000)
await settleCapacityCompletion()
const pendingFrame = iterator.next()
const outcome = await Promise.race([
pendingFrame.then(() => 'frame' as const),
new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }),
])
expect(outcome).toBe('idle')
controller.abort()
await expect(pendingFrame).resolves.toMatchObject({ done: true })
await iterator.return?.()
detachAgent()
lifecycle.detach()
await ctx.fiber.dispose()
})
it('does not read the sessions service after its disposal status', async () => {
let sessionsFiber: Fiber | undefined
const ctx = await hostContext((fiber) => { sessionsFiber = fiber })
@@ -720,7 +861,7 @@ describe('Web session model selection', () => {
detachAgent()
lifecycle.detach()
await ctx.fiber.dispose()
expect(agentReads).toBe(serviceName === 'sessions' ? 1 : 0)
expect(agentReads).toBe(0)
expect(sessionReads).toBe(0)
expect(outcome).toBe('idle')
},