fix(api-gateway): harden remote lifecycle and recovery

This commit is contained in:
imccyu
2026-08-07 21:47:17 +08:00
parent e89d078819
commit 686ee5b3f6
21 changed files with 218 additions and 34 deletions
+10 -10
View File
@@ -134,7 +134,8 @@ class ClientApiService extends Service implements TypeRTClientApi {
for (const method of methods) record.service.assertMethodAvailable(method)
} else {
for (const method of methods) ScopedRemoteNamespace.assertMethodAvailable(namespace, method)
if (this.ownerCtx.reflect.props[namespace] !== undefined) {
const property = this.ownerCtx.reflect.props[namespace]
if (property?.type === 'accessor' || this.ownerCtx.get(namespace) !== undefined) {
throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`)
}
}
@@ -224,6 +225,7 @@ class ClientApiService extends Service implements TypeRTClientApi {
if (namespace.tokens.get(descriptor.method) !== token) return
namespace.service.remove(descriptor.method)
namespace.tokens.delete(descriptor.method)
if (namespace.tokens.size === 0) this.scoped.delete(descriptor.namespace)
}
}
@@ -289,7 +291,7 @@ class ScopedRemoteNamespace {
private readonly ctx: Context
private readonly ownerCtx: Context
private readonly methods = new Set<string>()
private provided = false
private disposeService: (() => void) | undefined
readonly name: string
static assertMethodAvailable(namespace: string, method: string): void {
@@ -331,12 +333,7 @@ class ScopedRemoteNamespace {
},
})
if (activate) {
if (this.provided) {
this.ownerCtx.set(this.name, this)
} else {
this.ownerCtx.reflect.provide(this.name, this)
this.provided = true
}
this.disposeService = this.ownerCtx.reflect.provide(this.name, this)
}
} catch (error) {
Reflect.deleteProperty(this, method)
@@ -348,11 +345,14 @@ class ScopedRemoteNamespace {
remove(method: string): void {
Reflect.deleteProperty(this, method)
this.methods.delete(method)
if (this.methods.size === 0) this.ownerCtx.set(this.name, undefined)
if (this.methods.size !== 0) return
const disposeService = this.disposeService
this.disposeService = undefined
disposeService?.()
}
}
const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx', 'provided'])
const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'disposeService', 'invokeRemote', 'methods', 'name', 'ownerCtx'])
function endpointOf(descriptor: Pick<InvocationDescriptor, 'namespace' | 'method'>): string {
return `${descriptor.namespace}/${descriptor.method}`
+5 -4
View File
@@ -134,7 +134,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
const endpoint = endpointOf(request.namespace, request.method)
const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint)
assertExactArguments(request.args, descriptor, endpoint)
const receiverContext = this.resolveReceiverContext(descriptor, request.args, endpoint)
const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint)
const receiver = receiverContext.get(descriptor.service) as unknown
if (!isObject(receiver)) {
throw new TypertGatewayError(
@@ -331,11 +331,11 @@ export class TypertGatewayService extends Service implements TypertGateway {
}
}
private resolveReceiverContext(
private async resolveReceiverContext(
descriptor: InvocationDescriptor,
args: Readonly<Record<string, unknown>>,
endpoint: string,
): Context {
): Promise<Context> {
if (descriptor.invocation.kind === 'direct') return this.ctx
const invocation = descriptor.invocation
const provider = this.ctx.typert.contexts.getHost(invocation.context)
@@ -358,8 +358,9 @@ export class TypertGatewayService extends Service implements TypertGateway {
const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire)
let context: Context | undefined
try {
context = provider.resolve(identity)
context = await provider.resolve(identity)
} catch (cause) {
if (cause instanceof TypeRTLookupFailure) throw cause
throw new TypertGatewayError(
'context-failed',
endpoint,
+14
View File
@@ -526,6 +526,20 @@ describe('Client TypeRT API', () => {
await retry()
})
it('unregisters an empty scoped namespace so another provider can claim its name', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const dispose = ctx.api.mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] })
expect(ctx.get('goals')).toBeDefined()
await dispose()
expect(ctx.get('goals')).toBeUndefined()
const replacement = { owner: 'replacement' }
const disposeReplacement = ctx.reflect.provide('goals', replacement)
expect(ctx.get('goals')).toBe(replacement)
await disposeReplacement()
})
it('throws RPC failures with the structured error as its cause', async () => {
const rpcError = { code: 'internal' as const, message: 'host failed', details: {} }
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({ ok: false, error: rpcError }))
@@ -538,6 +538,22 @@ describe('TypertGatewayService', () => {
expect(error.cause).toEqual(new Error('provider failed'))
})
it('preserves a Host Context policy rejection for the active RPC adapter', async () => {
const { ctx } = await setup()
const rejection = new TypeRTLookupFailure({ code: 'agent-busy', message: 'owned', details: { reason: 'subagent' } })
ctx.typert.contexts.registerHost('gatewayFixture', {
...contextProvider(ctx.extend()),
resolve: async () => { throw rejection },
})
registerStrict(ctx, [renameDescriptor()])
await expect(ctx.typertGateway.invoke({
namespace: 'goals',
method: 'rename',
args: { agentId: 'agent-1', request: { title: 'land' } },
})).rejects.toBe(rejection)
})
it('reports Context provider metadata mismatch and unresolved identities', async () => {
const { ctx } = await setup()
registerStrict(ctx, [renameDescriptor()])
+1
View File
@@ -187,6 +187,7 @@ export function createApiRemoteAgentResolver(
}
typeCtx.typert.lookups.configure('agent', resolveAgent)
typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session)
typeCtx.typert.contexts.configureHost('agent', async sessionId => (await resolveAgent(sessionId)).ctx)
})
return agentFor
@@ -5,6 +5,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { createApiRemoteAgentResolver } from '@deepseek-ai/dsh-api-remotes'
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
const sid = (value: string): SessionId => value as SessionId
@@ -14,6 +16,7 @@ function header(id: SessionId): SessionHeader {
async function createContext(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
return ctx
@@ -107,4 +110,45 @@ describe('API Remote Agent resolver races', () => {
await ctx.fiber.dispose()
}
})
it('uses the shared cold-resume policy for the Agent Host Context', async () => {
const ctx = await createContext()
const sessionId = sid('context-cold-resume')
const meta = header(sessionId)
let published: Session | undefined
provideSession(ctx, meta, () => {
published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } })
return Promise.resolve({ meta, events: [] })
})
const agentCtx = ctx.extend()
vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => {
if (published === undefined) throw new Error('Session was not published')
return { agent: stubAgent(agentCtx, published), dispose: () => Promise.resolve() }
})
const defaultProvider = ctx.typert.contexts.getHost('agent')
createApiRemoteAgentResolver(ctx, {})
await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) })
const provider = ctx.typert.contexts.getHost('agent')
if (provider === undefined) throw new Error('Agent Host Context provider was not mounted')
await expect(provider.resolve(sessionId)).resolves.toBe(agentCtx)
await ctx.fiber.dispose()
})
it('applies the subagent ownership fence to the Agent Host Context', async () => {
const ctx = await createContext()
const sessionId = sid('context-owned-subagent')
const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } })
ctx.agents.register(stubAgent(ctx.extend(), session))
const defaultProvider = ctx.typert.contexts.getHost('agent')
createApiRemoteAgentResolver(ctx, {})
await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) })
const provider = ctx.typert.contexts.getHost('agent')
if (provider === undefined) throw new Error('Agent Host Context provider was not mounted')
const resolution = provider.resolve(sessionId)
await expect(resolution).rejects.toBeInstanceOf(TypeRTLookupFailure)
await expect(resolution).rejects.toMatchObject({ failure: { code: 'agent-busy' } })
await ctx.fiber.dispose()
})
})