fix(client): materialize Agent scopes before list baseline

This commit is contained in:
imccyu
2026-08-23 16:16:05 +08:00
parent d319a0773b
commit 18cf84d133
5 changed files with 49 additions and 4 deletions
@@ -258,11 +258,11 @@ WebSocket JSON 与进程内 carrier 的入口都从 `unknown` 开始按 `type`
Host 只投影 request 一级的 `agent``signal``agent` 变为 frame 的一级 `agentId``signal` 成为 delivery lifetime,其余字段必须整体为无损 JSON。
Client 用 `agentId` 同步解析已存在的 Agent Context,把当前 delivery signal 放回 request 的直接 `signal` 字段,再在目标 Context 的私有 key 上调用 Cordis `waterfall()`
Client 用 `agentId` 同步解析或物化 Agent Context,把当前 delivery signal 放回 request 的直接 `signal` 字段,再在目标 Context 的私有 key 上调用 Cordis `waterfall()`Session-backed adapter 在首个成功 Session 列表 baseline 到达前允许 transport 先物化 scopebaseline 到达后由列表生命周期接管 scope 存活判断。
系统不扫描任意深度对象,不传 path array 或 placeholder,不 deep clonerestore Context 和 AbortSignal,也不等待未来出现的 Agent Context。
Client adapter 未注册、Agent Context 不存在或已经释放时,本 Client 立即返回 `next`。它不订阅 registry、不做 resolve 后竞态复查,也不为一次 delivery 创建临时 Fiber。
Client adapter 未注册、resolver 未返回 Context 或解析抛错时,本 Client 立即返回 `next`。它不订阅 registry、不做 resolve 后竞态复查,也不为一次 delivery 创建临时 Fiber。
Gateway Host 为每个未完成 waterfall 保存 `eventId`、Host continuation 与已投递 Client generation。新 Client generation 会收到同一 pending event 的重放。
@@ -310,7 +310,7 @@ API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace
**把 Agent scope 做成任意深度对象投影。** 递归扫描 Context 与 AbortSignal 需要 path、placeholder、clone 和 restore 协议,并把偶然对象结构升级成 wire 约定;一级 `agent``signal` 足以覆盖当前 waterfall。
**等待 Client Agent Context 或 adapter 后再分发。** registry waiter、竞态复查和临时 delivery Fiber 会为一个可直接委托的 Client 增加额外生命周期;目标不存在时立即 `next` 保持 Cordis waterfall 语义。
**等待 Client Agent Context 或 adapter 后再分发。** registry waiter、竞态复查和临时 delivery Fiber 会为一个可同步解析或物化目标的 Client 增加额外生命周期;resolver 当下不能提供目标时立即 `next` 保持 Cordis waterfall 语义。
**给 Remote Event 使用独立物理 WebSocket 或 duplex stream。** Gateway mux 已提供认证升级、复用、取消、错误映射和重连;下行 `$events` 加上 HTTP `$events/result` 足以表达 requestresponse,不需要第三条连接。
+1 -1
View File
@@ -225,7 +225,7 @@ export function apply(ctx: Context): void {
sessionControl.start()
ctx.typert.contexts.registerClient('agent', {
identity: candidate => sessions.scopeOf(candidate),
resolve: sessionId => sessions.scope(sessionId),
resolve: sessionId => sessions.resolveAgentScope(sessionId),
})
const workspaceModel = new ClientWorkspaceModel(ctx.remote.workspace)
const workspaces = new WorkspaceRuntime(ctx, connection.api, workspaceModel, sessions)
@@ -572,6 +572,18 @@ export class SessionRuntime implements ISessions {
return this.resolve(id)?.ctx
}
/**
* Materialize the Agent scope named by a validated Host Remote Event.
* The first successful Session-list baseline becomes authoritative for its
* lifetime; until then, transport streams may address the scope in either
* arrival order.
* @param id - Host-projected Agent identity (the matching Session id).
* @returns the identity-stable Agent Context.
*/
resolveAgentScope(id: SessionId): AgentContext {
return (this.scopes.get(id) ?? this.materializeScope(id)).ctx
}
/**
* Read the Agent scope tag off a context. Service-method boundary: fetch
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
@@ -664,6 +676,11 @@ export class SessionRuntime implements ISessions {
const existing = this.scopes.get(id)
if (existing !== undefined) return existing
if (!this.eligible(id)) return undefined
return this.materializeScope(id)
}
/** Materialize one scope after its caller establishes that the id may be addressed. */
private materializeScope(id: SessionId): ScopeRecord {
const { fiber, ctx } = createScope(this.rootCtx, id)
const session = this.manager.get(id)
// The Session owns its scoped dispatch point (host Agent.loopCtx mirror);
@@ -764,6 +781,7 @@ export class SessionRuntime implements ISessions {
/** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
private pruneScopes(): void {
if (this.list.getSnapshot().phase === 'pending') return
for (const [id, record] of this.scopes) {
if (this.eligible(id)) continue
if (id === this.watched) {
@@ -10,6 +10,7 @@ import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-api-session-contro
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import * as RuntimeClient from '../src/client/index.ts'
import type { ConversationNodeDefinition } from '../src/client/contract/conversation.ts'
import { scopeOf } from '../src/client/agents/scope.ts'
import { Session } from '../src/client/sessions/session.ts'
import { SessionRuntime } from '../src/client/sessions/service.ts'
import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts'
@@ -68,6 +69,16 @@ async function flushMicrotasks(): Promise<void> {
}
describe('runtime client apply', () => {
it('materializes Host-addressed Agent scopes before the Session list arrives', async () => {
const bench = await mount()
const adapter = bench.ctx.typert.contexts.getClient('agent')
const first = adapter?.resolve('s-early')
expect(first).toBeDefined()
expect(scopeOf(first as Context)).toBe('s-early')
expect(adapter?.resolve('s-early')).toBe(first)
})
it('refreshes Sessions on every Gateway connection generation', async () => {
const refresh = vi.spyOn(SessionRuntime.prototype, 'handleConnected')
const bench = await mount()
@@ -121,6 +121,22 @@ describe('search', () => {
})
describe('scope tree', () => {
it('retains a Host-addressed scope until the first Session baseline owns pruning', async () => {
const b = bench()
const scoped = b.svc.resolveAgentScope(sid('s-early'))
expect(scopeOf(scoped)).toBe('s-early')
b.svc.handleControlFrame({
type: 'baseline',
value: { queues: {}, jobs: {}, projections: {} },
})
await Promise.resolve()
expect(b.svc.resolveAgentScope(sid('s-early'))).toBe(scoped)
await feedList(b, [])
expect(b.svc.scope(sid('s-early'))).toBeUndefined()
})
it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])