فهرست منبع

refactor(subagent): keep preset teardown outside identity changes

_Kerman 2 هفته پیش
والد
کامیت
3a98d05a3d

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml

@@ -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 .agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md
-2026-08-31-explicit-agent-runtime-identity.md: 48c8d0c205b6b8c2331e2373b1e11142e05d715b
-2026-08-31-explicit-agent-runtime-identity.zh.md: a8358f5a8f4629289dc75722ed82d79206db3dc5
+2026-08-31-explicit-agent-runtime-identity.md: f52b8ec116c312a27306fe73dc0bd5b99fcd9039
+2026-08-31-explicit-agent-runtime-identity.zh.md: 6b6fd2f2ea1f1645069264f09fd53c3f71e1d02f

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md

@@ -16,7 +16,7 @@ Without an explicit owner, `SubagentContinuationManager` creates and resumes chi
 
 Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` receives `(agentCtx, agent)`; Agent creation and resume options carry `parentAgent` for a runtime child; scoped events carry their Agent in the payload; Remote forwarding verifies that `request.agent` is the carrier key; and Host Typert Context resolution maps wire identity to a live Agent Context without a reverse scan. `agent.ctx` remains the registration and lifecycle owner and exposes no reverse Agent property.
 
-Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` passes the unpublished Session explicitly and installs through the supplied Context before publication. A settings-backed standing preset reserves one Cordis cleanup effect for each matching Agent before sampling policy: the event payload supplies the Agent, its Session supplies the policy target, its Context owns the registrations, and the preset effect joins their removal after reparenting or preset unload.
+Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` passes the unpublished Session explicitly and installs through the supplied Context before publication. For a settings-backed standing preset, the event payload supplies the Agent, its Session supplies the policy target, and its Context owns the registrations.
 
 `SubagentContinuationManager` puts the exact parent in both fresh-creation and cold-resume options. A live continuable child is therefore excluded from `AgentRegistry.roots()` and satisfies `isOwnedBy(child.id, parent)`. Durable `parentSession` metadata does not substitute for this relation: a fork or resumed Session may be a runtime root when no live Agent owns it.
 
@@ -26,7 +26,7 @@ The [Agent registration-scope decision](2026-07-08-agent-scope-contexts.md), its
 
 Agent creation tests pin explicit root and child ownership. Continuation integration tests keep a real child live long enough to assert both `roots()` exclusion and `isOwnedBy()` membership. Existing Schedule tests verify that root-only registrations stay absent from an explicitly owned child.
 
-Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Session publication; standing-preset tests verify per-Session policy sampling, inheritance, and removal of every installed definition before preset unload settles, including cleanup already started by reparenting or Agent disposal.
+Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Session publication; standing-preset tests verify per-Session policy sampling and inheritance.
 
 ## Alternatives considered
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md

@@ -16,7 +16,7 @@ Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操
 
 运行时接口在拥有身份的位置携带 Agent 身份。`AgentSetup` 接收 `(agentCtx, agent)`;创建与恢复 Agent 的 options 通过 `parentAgent` 标识运行时子级;作用域事件在 payload 中携带 Agent;Remote 转发校验 `request.agent` 就是 carrier key;Host Typert Context 解析则把协议身份映射到存活 Agent Context,不执行反向扫描。`agent.ctx` 继续拥有注册和生命周期,不暴露反向 Agent 属性。
 
-感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。由设置控制的常驻 preset 在读取策略前,会为每个匹配 Agent 预留一个 Cordis 清理 effect:事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项,而 preset effect 会在重设父级或 preset 卸载后等待其清理完成。
+感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。对于由设置控制的常驻 preset,事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项。
 
 `SubagentContinuationManager` 会把确切父级放进全新创建与冷恢复的 options。因此,存活的可续跑子级不会出现在 `AgentRegistry.roots()` 中,并且满足 `isOwnedBy(child.id, parent)`。持久化 `parentSession` 元数据不能代替这项关系:没有存活 Agent 拥有 fork 或已恢复会话时,它仍可成为 runtime root。
 
@@ -26,7 +26,7 @@ Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操
 
 Agent 创建测试锁定显式的根级与子级归属。continuation 集成测试让一个真实子级保持存活,直到断言其既不属于 `roots()`、又满足 `isOwnedBy()`。现有 Schedule 测试验证仅限根级的注册项不会出现在显式归属的子级中。
 
-Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取、继承,以及 preset 卸载完成前会移除所有已安装定义,包括重设父级或 Agent 销毁时已启动的清理。
+Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取与继承。
 
 ## 考虑过的替代方案
 

+ 2 - 2
packages/subagent/tool-subagent/README.i18n.yaml

@@ -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/subagent/tool-subagent/README.md
-README.md: 01052e98ac9a8dcf3539ed20a9b0ca3ff5c6c2cd
-README.zh.md: d90bafdeec1c60ce3f96be1546cb95a4c614f084
+README.md: 97bb9b143f003c830b68ffae9e25dee588a8fa67
+README.zh.md: 1804f8f5da2dcd8fbdd043d247e6c234ea090529

+ 1 - 1
packages/subagent/tool-subagent/README.md

@@ -80,7 +80,7 @@ This section explains how the tool mirrors provider lifecycle and settles runs;
 
 ### Design concept
 
-One instance is one provider plus one tool name. The plugin mirrors provider lifecycle: it registers the tool when the named provider appears and disposes it when the provider leaves, so sibling load order and HMR replacement cannot strand a dangling tool. Direct Agent setup passes its unpublished Session explicitly and awaits installation before publication. A settings-backed standing preset receives each matching Agent from lifecycle events, selects policy from its Session, installs through its Context, and awaits removal of every owned fiber when the preset unloads, including cleanup already started by Agent disposal. A numeric `maxDepth` or configured LLM selection the provider cannot enforce fails the mount instead of the first delegation. At most one instance in a tool scope may own model selection because `list_subagent_models` has a global name.
+One instance is one provider plus one tool name. The plugin mirrors provider lifecycle: it registers the tool when the named provider appears and disposes it when the provider leaves, so sibling load order and HMR replacement cannot strand a dangling tool. Direct Agent setup passes its unpublished Session explicitly and awaits installation before publication. A settings-backed standing preset receives each matching Agent from lifecycle events, selects policy from its Session, and installs through its Context. A numeric `maxDepth` or configured LLM selection the provider cannot enforce fails the mount instead of the first delegation. At most one instance in a tool scope may own model selection because `list_subagent_models` has a global name.
 
 ### Foreground settlement
 

+ 1 - 1
packages/subagent/tool-subagent/README.zh.md

@@ -80,7 +80,7 @@ kind: "package-reference"
 
 ### 设计理念
 
-一个实例就是一个提供方加一个工具名称。插件镜像提供方生命周期:具名提供方出现时注册工具,提供方离开时释放工具,因此同级加载顺序与 HMR 替换不会让工具悬空。直接 Agent setup 显式传入尚未发布的 Session,并在发布前等待安装完成。由设置控制的常驻 preset 从生命周期事件接收每个匹配 Agent,从其 Session 选择策略,通过其 Context 安装,并在 preset 卸载时等待移除所有归它所有的 fiber,包括 Agent 销毁时已启动的清理。提供方无法执行的数值型 `maxDepth` 或已配置 LLM 选择会在挂载时失败,而不是在首次委派时失败。每个工具作用域内最多一个实例可以拥有模型选择,因为 `list_subagent_models` 使用全局名称。
+一个实例就是一个提供方加一个工具名称。插件镜像提供方生命周期:具名提供方出现时注册工具,提供方离开时释放工具,因此同级加载顺序与 HMR 替换不会让工具悬空。直接 Agent setup 显式传入尚未发布的 Session,并在发布前等待安装完成。由设置控制的常驻 preset 从生命周期事件接收每个匹配 Agent,从其 Session 选择策略,并通过其 Context 安装。提供方无法执行的数值型 `maxDepth` 或已配置 LLM 选择会在挂载时失败,而不是在首次委派时失败。每个工具作用域内最多一个实例可以拥有模型选择,因为 `list_subagent_models` 使用全局名称。
 
 ### 前台结算
 

+ 24 - 30
packages/subagent/tool-subagent/src/index.ts

@@ -8,7 +8,7 @@
  * @module @deepseek-ai/dsh-tool-subagent
  */
 
-import { FiberState, type Context } from '@deepseek-ai/cordis'
+import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope'
 import { defineTool } from '@deepseek-ai/dsh-tools'
@@ -657,57 +657,51 @@ export function apply(ctx: Context, config: Config, session?: Session): void {
   const agents = ctx.get('agents')
   /* v8 ignore next -- shipped preset compositions always include the Agent registry. */
   if (agents === undefined) throw new Error('tool-subagent: standing `modelSelectionSettings` requires the Agent registry')
-  const presetInstalls = new Map<Agent, () => Promise<void>>()
+  const scopedInstalls = new WeakMap<Agent, ReturnType<Context['inject']>>()
+  const installing = new WeakSet<Agent>()
   const belongsToComposition = (candidate: Agent): boolean =>
     scopeChainOf(scopeOf(candidate.ctx)).includes(compositionScope)
-  const installPresetDefinition = (candidate: Agent): void => {
-    if (ctx.fiber.uid === null || ctx.fiber.state === FiberState.UNLOADING || presetInstalls.has(candidate)) return
-    let fiber: ReturnType<Context['inject']> | undefined
-    const dispose = ctx.effect(() => async () => {
-      if (fiber !== undefined) {
-        await fiber.dispose()
-        // Agent teardown may already have claimed the single-shot disposer.
-        while (fiber.inertia !== undefined) await fiber.inertia
-      }
-    }, `tool-subagent: standing-preset definitions for Agent "${candidate.id}"`)
-    // Reserve before policy sampling or injection can re-enter reconciliation.
-    presetInstalls.set(candidate, dispose)
+  const installScoped = (candidate: Agent): void => {
+    if (scopedInstalls.has(candidate) || installing.has(candidate)) return
+    // Reserve before the injected fiber runs: tool registration emits
+    // `tools/change` synchronously, which re-enters the reconciliation below.
+    installing.add(candidate)
+    let fiber: ReturnType<Context['inject']>
     try {
       const policy = selectForSession(candidate.session)
       fiber = candidate.ctx.inject(['tools', 'subagents', 'systemPrompt'], (runtimeCtx) => {
         install(runtimeCtx, policy)
       })
-    } catch (error) {
-      presetInstalls.delete(candidate)
-      void dispose()
-      throw error
+    } finally {
+      installing.delete(candidate)
     }
+    scopedInstalls.set(candidate, fiber)
   }
-  const removePresetDefinition = (candidate: Agent): void => {
-    const dispose = presetInstalls.get(candidate)
-    if (dispose === undefined) return
-    presetInstalls.delete(candidate)
+  const removeScoped = (candidate: Agent): void => {
+    const fiber = scopedInstalls.get(candidate)
+    if (fiber === undefined) return
+    scopedInstalls.delete(candidate)
     /* v8 ignore next 3 -- Cordis Fiber disposal contains registration cleanup failures; this is the final diagnostic sink. */
-    void dispose().catch((error: unknown) => {
+    void fiber.dispose().catch((error: unknown) => {
       ctx.logger.warn(`tool-subagent: failed to remove recomposed Agent "${candidate.id}" definitions: ${String(error)}`)
     })
   }
-  const reconcilePresetDefinitions = (): void => {
+  const reconcileComposedAgents = (): void => {
     for (const candidate of agents.list()) {
-      if (belongsToComposition(candidate)) installPresetDefinition(candidate)
-      else removePresetDefinition(candidate)
+      if (belongsToComposition(candidate)) installScoped(candidate)
+      else removeScoped(candidate)
     }
   }
   // The preset-scoped listener admits descendant Agents and installs the
   // sampled tool definition in each Agent's own scope, so a later settings
   // change cannot mutate a live session.
   ctx.on('agent/created', ({ agent: created }) => {
-    installPresetDefinition(created)
+    installScoped(created)
   })
-  ctx.on('agent/disposed', ({ agent: disposed }) => { removePresetDefinition(disposed) })
+  ctx.on('agent/disposed', ({ agent: disposed }) => { removeScoped(disposed) })
   // Reparenting an Agent between standing presets changes its inherited tool
   // set and emits `tools/change`; reconcile the Agent-owned override with the
   // new ancestry. Other registry changes are idempotent no-ops here.
-  ctx.on('tools/change', reconcilePresetDefinitions)
-  reconcilePresetDefinitions()
+  ctx.on('tools/change', reconcileComposedAgents)
+  reconcileComposedAgents()
 }

+ 1 - 78
packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts

@@ -241,19 +241,7 @@ describe('SubagentModelSelectionConfig', () => {
     await ctx.plugin(ToolInvariant)
     const preset = createScope(ctx, { preset: 'standard' })
     const other = createScope(ctx, { preset: 'minimal' })
-    const cleanupGate = Promise.withResolvers<undefined>()
-    const cleanupStarted = Promise.withResolvers<undefined>()
-    const cleanup = { done: false, blockedParent: undefined as Context | undefined }
-    ctx.on('internal/plugin', (fiber) => {
-      if (Object.keys(fiber.inject).sort().join(',') !== 'subagents,systemPrompt,tools') return
-      fiber.ctx.effect(() => async () => {
-        if (fiber.parent !== cleanup.blockedParent) return
-        cleanupStarted.resolve(undefined)
-        await cleanupGate.promise
-        cleanup.done = true
-      }, 'tool-subagent test: delayed preset cleanup')
-    })
-    const mounted = await preset.ctx.plugin(tool, {
+    await preset.ctx.plugin(tool, {
       provider: 'spawn',
       modelSelectionSettings: true,
       backgroundMode: 'continuable',
@@ -298,77 +286,12 @@ describe('SubagentModelSelectionConfig', () => {
     await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next))
       .resolves.toEqual({ kind: 'enter', messages: [] })
 
-    cleanup.blockedParent = enabled.agent.ctx
-    enabledBinding!.rebind(scopeOf(other.ctx)!)
-    ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change')
-    await cleanupStarted.promise
-    const unloading = mounted.dispose()
-    setImmediate(() => { cleanupGate.resolve(undefined) })
-    await unloading
-    const cleanupFinishedAtUnload = cleanup.done
     await enabled.dispose()
-    expect(cleanupFinishedAtUnload).toBe(true)
-    expect(selectable(ctx, enabled.agent)).toBe(false)
-    expect(selectable(ctx, disabled.agent)).toBe(false)
-
     ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change')
     await disabled.dispose()
     await ctx.fiber.dispose()
   })
 
-  it('awaits Agent-started definition cleanup when a standing preset unloads', async () => {
-    const ctx = await boot(false)
-    const cleanupStarted = Promise.withResolvers<undefined>()
-    const cleanupGate = Promise.withResolvers<undefined>()
-    const settlements: string[] = []
-    let definitionParent: Context | undefined
-    let disposingAgent: Promise<void> | undefined
-    let unloadingPreset: Promise<void> | undefined
-    try {
-      const preset = createScope(ctx, { preset: 'agent-first-cleanup' })
-      ctx.on('internal/plugin', (fiber) => {
-        if (fiber.parent !== definitionParent
-          || Object.keys(fiber.inject).sort().join(',') !== 'subagents,systemPrompt,tools') return
-        fiber.ctx.effect(() => async () => {
-          cleanupStarted.resolve(undefined)
-          await cleanupGate.promise
-          settlements.push('definition-cleaned')
-        }, 'tool-subagent test: Agent-started cleanup barrier')
-      })
-      const mounted = await preset.ctx.plugin(tool, {
-        provider: 'spawn',
-        modelSelectionSettings: true,
-        backgroundMode: 'continuable',
-      })
-      const handle = await ctx.agents.create({
-        sessionId: SessionId('agent-first-cleanup'),
-        setup: (agentCtx) => {
-          definitionParent = agentCtx
-          bindScopeParent(scopeOf(agentCtx)!, scopeOf(preset.ctx)!)
-        },
-      })
-      await vi.waitFor(() => { expect(ctx.tools.get('subagent', handle.agent)).toBeDefined() })
-
-      disposingAgent = handle.dispose()
-      await cleanupStarted.promise
-      unloadingPreset = Promise.resolve(mounted.dispose()).then(() => {
-        settlements.push('preset-unloaded')
-      })
-      setImmediate(() => { cleanupGate.resolve(undefined) })
-      await unloadingPreset
-      await disposingAgent
-      expect(settlements).toEqual(['definition-cleaned', 'preset-unloaded'])
-      expect(ctx.tools.get('subagent', handle.agent)).toBeUndefined()
-    } finally {
-      cleanupGate.resolve(undefined)
-      try {
-        await Promise.all([disposingAgent, unloadingPreset])
-      } finally {
-        await ctx.fiber.dispose()
-      }
-    }
-  })
-
   it('releases a shared-preset installation reservation after policy selection fails', async () => {
     const ctx = await boot(false)
     const preset = createScope(ctx, { preset: 'standard' })