Browse Source

fix(tool-subagent): await agent-started preset cleanup

_Kerman 4 weeks ago
parent
commit
e7bde97aa0

+ 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;
 # 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:
 # 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
 #   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: 6f0fafa243ee6843981bbb78af626988c4131f80
-2026-08-31-explicit-agent-runtime-identity.zh.md: 703ccd0384003c30e4c402e96e6a04083e473024
+2026-08-31-explicit-agent-runtime-identity.md: 48c8d0c205b6b8c2331e2373b1e11142e05d715b
+2026-08-31-explicit-agent-runtime-identity.zh.md: a8358f5a8f4629289dc75722ed82d79206db3dc5

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

@@ -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.
 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.
+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.
 
 
 ## Alternatives considered
 ## Alternatives considered
 
 

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

@@ -26,7 +26,7 @@ Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操
 
 
 Agent 创建测试锁定显式的根级与子级归属。continuation 集成测试让一个真实子级保持存活,直到断言其既不属于 `roots()`、又满足 `isOwnedBy()`。现有 Schedule 测试验证仅限根级的注册项不会出现在显式归属的子级中。
 Agent 创建测试锁定显式的根级与子级归属。continuation 集成测试让一个真实子级保持存活,直到断言其既不属于 `roots()`、又满足 `isOwnedBy()`。现有 Schedule 测试验证仅限根级的注册项不会出现在显式归属的子级中。
 
 
-Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取、继承,以及 preset 卸载完成前会移除所有已安装定义,包括重设父级时已启动的清理。
+Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取、继承,以及 preset 卸载完成前会移除所有已安装定义,包括重设父级或 Agent 销毁时已启动的清理。
 
 
 ## 考虑过的替代方案
 ## 考虑过的替代方案
 
 

+ 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;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/subagent/tool-subagent/README.md
 #   pnpm run verify-translation-pairing --write packages/subagent/tool-subagent/README.md
-README.md: 35fcc0e65bd3ab9c9a0704e6085f5d731aff085c
-README.zh.md: 4a95bf6dcee314159899f1b10a988a09ce6cce69
+README.md: 01052e98ac9a8dcf3539ed20a9b0ca3ff5c6c2cd
+README.zh.md: d90bafdeec1c60ce3f96be1546cb95a4c614f084

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

+ 5 - 1
packages/subagent/tool-subagent/src/index.ts

@@ -664,7 +664,11 @@ export function apply(ctx: Context, config: Config, session?: Session): void {
     if (ctx.fiber.uid === null || ctx.fiber.state === FiberState.UNLOADING || presetInstalls.has(candidate)) return
     if (ctx.fiber.uid === null || ctx.fiber.state === FiberState.UNLOADING || presetInstalls.has(candidate)) return
     let fiber: ReturnType<Context['inject']> | undefined
     let fiber: ReturnType<Context['inject']> | undefined
     const dispose = ctx.effect(() => async () => {
     const dispose = ctx.effect(() => async () => {
-      if (fiber !== undefined) await fiber.dispose()
+      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}"`)
     }, `tool-subagent: standing-preset definitions for Agent "${candidate.id}"`)
     // Reserve before policy sampling or injection can re-enter reconciliation.
     // Reserve before policy sampling or injection can re-enter reconciliation.
     presetInstalls.set(candidate, dispose)
     presetInstalls.set(candidate, dispose)

+ 53 - 0
packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts

@@ -316,6 +316,59 @@ describe('SubagentModelSelectionConfig', () => {
     await ctx.fiber.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 () => {
   it('releases a shared-preset installation reservation after policy selection fails', async () => {
     const ctx = await boot(false)
     const ctx = await boot(false)
     const preset = createScope(ctx, { preset: 'standard' })
     const preset = createScope(ctx, { preset: 'standard' })