Explorar el Código

fix(tool-subagent): simplify standing preset cleanup

_Kerman hace 1 mes
padre
commit
8292ea45ba

+ 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: b9b97ba71d7f6bb9b3589a2c42cf638908a9bcae
-2026-08-31-explicit-agent-runtime-identity.zh.md: e61c27211081192877d8c471002d9fc921678fa4
+2026-08-31-explicit-agent-runtime-identity.md: 9177688866bf6b0c9c4a50f2b9169d4bc6a66c65
+2026-08-31-explicit-agent-runtime-identity.zh.md: 0cede058cb19c7f6a55d49d58e40c795f8c32a33

+ 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 receive an explicit runtime owner; 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 keeps its existing lifecycle path: the event payload supplies the matching Agent, its Session supplies the policy target, and its Context owns the registrations.
+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.
 
 `SubagentContinuationManager` passes the exact parent to both fresh creation and cold resume. 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 on preset unload.
+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.
 
 ## 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 创建与恢复接收显式运行时所属方;作用域事件在 payload 中携带 Agent;Remote 转发校验 `request.agent` 就是 carrier key;Host Typert Context 解析则把协议身份映射到存活 Agent Context,不执行反向扫描。`agent.ctx` 继续拥有注册和生命周期,不暴露反向 Agent 属性。
 
-感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。由设置控制的常驻 preset 则保留既有生命周期路径:事件 payload 提供匹配 Agent,其 Session 提供策略目标,其 Context 拥有注册项。
+感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。由设置控制的常驻 preset 在读取策略前,会为每个匹配 Agent 预留一个 Cordis 清理 effect:事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项,而 preset effect 会在重设父级或 preset 卸载后等待其清理完成。
 
 `SubagentContinuationManager` 会把确切父级同时传给全新创建与冷恢复。因此,存活的可续跑子级不会出现在 `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 卸载时会移除所有已安装定义。
+Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取、继承,以及 preset 卸载完成前会移除所有已安装定义,包括重设父级时已启动的清理。
 
 ## 考虑过的替代方案
 

+ 16 - 29
packages/subagent/tool-subagent/src/index.ts

@@ -8,7 +8,7 @@
  * @module @deepseek-ai/dsh-tool-subagent
  */
 
-import type { Context } from '@deepseek-ai/cordis'
+import { FiberState, 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,50 +657,37 @@ 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, ReturnType<Context['inject']>>()
-  const installing = new WeakSet<Agent>()
-  let stopping = false
+  const presetInstalls = new Map<Agent, () => Promise<void>>()
   const belongsToComposition = (candidate: Agent): boolean =>
     scopeChainOf(scopeOf(candidate.ctx)).includes(compositionScope)
   const installPresetDefinition = (candidate: Agent): void => {
-    if (stopping || presetInstalls.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']>
+    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()
+    }, `tool-subagent: standing-preset definitions for Agent "${candidate.id}"`)
+    // Reserve before policy sampling or injection can re-enter reconciliation.
+    presetInstalls.set(candidate, dispose)
     try {
       const policy = selectForSession(candidate.session)
       fiber = candidate.ctx.inject(['tools', 'subagents', 'systemPrompt'], (runtimeCtx) => {
         install(runtimeCtx, policy)
       })
-    } finally {
-      installing.delete(candidate)
+    } catch (error) {
+      presetInstalls.delete(candidate)
+      void dispose()
+      throw error
     }
-    presetInstalls.set(candidate, fiber)
   }
   const removePresetDefinition = (candidate: Agent): void => {
-    const fiber = presetInstalls.get(candidate)
-    if (fiber === undefined) return
+    const dispose = presetInstalls.get(candidate)
+    if (dispose === undefined) return
     presetInstalls.delete(candidate)
     /* v8 ignore next 3 -- Cordis Fiber disposal contains registration cleanup failures; this is the final diagnostic sink. */
-    void fiber.dispose().catch((error: unknown) => {
+    void dispose().catch((error: unknown) => {
       ctx.logger.warn(`tool-subagent: failed to remove recomposed Agent "${candidate.id}" definitions: ${String(error)}`)
     })
   }
-  ctx.effect(() => async () => {
-    stopping = true
-    const fibers = [...presetInstalls.values()]
-    presetInstalls.clear()
-    const outcomes = await Promise.allSettled(fibers.map(fiber => fiber.dispose()))
-    const failures: unknown[] = []
-    for (const outcome of outcomes) {
-      if (outcome.status === 'rejected') failures.push(outcome.reason as unknown)
-    }
-    if (failures.length === 1) throw failures[0]
-    if (failures.length > 1) {
-      throw new AggregateError(failures, 'tool-subagent: failed to remove standing-preset definitions')
-    }
-  })
   const reconcilePresetDefinitions = (): void => {
     for (const candidate of agents.list()) {
       if (belongsToComposition(candidate)) installPresetDefinition(candidate)

+ 22 - 2
packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts

@@ -242,6 +242,18 @@ 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, {
       provider: 'spawn',
       modelSelectionSettings: true,
@@ -287,11 +299,19 @@ describe('SubagentModelSelectionConfig', () => {
     await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next))
       .resolves.toEqual({ kind: 'enter', messages: [] })
 
-    await mounted.dispose()
+    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)
 
-    await enabled.dispose()
     ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change')
     await disabled.dispose()
     await ctx.fiber.dispose()