Sfoglia il codice sorgente

fix(tool-tasks): claim completion notices only for the mount's own scope

Moving the task registry to the host plane put every preset's `tool-tasks`
listener on ONE `LocalTaskService`. `settle()` computes a single snapshot and
walks every registered listener with no scope filter, and it marks `reported`
only when a waiter is present — so a task settling without a waiter reached
each mount's listener with `reported` false and every one of them injected the
same completion into the same owner. Three shipped presets carry `tool-tasks`,
and a preset file edit adds a second generation of the same mount, so an agent
read N copies of one notice as model-visible durable context.

A mount now claims an owner only when the owner's scope chain reaches the
mount's own scope. An unscoped mount is the host-plane instance that serves
every agent, which keeps the TUI composition and every existing test intact.

Registry-side ownership was the alternative: mark `reported` once the first
listener claims it. It is wrong because `onTaskDone` is not a notice-only
seam — the `dsh-tasks` invariant companion registers a validating listener —
so first-claim-wins would silence observers that are not delivering anything.

The regression test mounts two scoped `tool-tasks` over one registry and
settles an unowned-wait task, which is the only path that reaches the notice
listeners at all: the shipped-composition e2e uses `wait: true`, and a waiter
marks `reported` before settlement, so that test structurally cannot cover it.

Also corrects the standing-mounts Agent Note, which still listed `tasks-local`
among the stateful PRESET plugins.

Refs #2141
Yichen Jiang 1 mese fa
parent
commit
37ebe87087

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.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-08-per-preset-standing-mounts.md
-2026-08-08-per-preset-standing-mounts.md: 834d645f5f293a2e137b8faf662e301f1e8bb971
-2026-08-08-per-preset-standing-mounts.zh.md: 45ce0f4e7dec28e5bf807898dc9cdbf32b8e4eb5
+2026-08-08-per-preset-standing-mounts.md: c2792454f90a88cd6fba36eed8e36104e5fffea4
+2026-08-08-per-preset-standing-mounts.zh.md: 47668c8c2c424eb188aa14bf55986d27bcfb8ee0

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md

@@ -16,7 +16,7 @@ A preset is one composition per PROCESS, not one per session. The roster mounts
 
 Standing mounts fix the class, not the instances: the registrations a reader needs exist for the process lifetime, keyed by preset id, no agent required. What made it cheap
 
-- The stateful preset plugins (`plan-mode`, `token-meter`, `compact-basic`, `tasks-local`) already key state by `Session`/`Agent` — they predate presets. Sharing one instance is a return to their design, not a rewrite.
+- The stateful preset plugins (`plan-mode`, `token-meter`, `compact-basic`) already key state by `Session`/`Agent` — they predate presets. Sharing one instance is a return to their design, not a rewrite. `tasks-local` shared that property and has since left the preset plane entirely: producers outside its realm (`tool-bash`, `tool-pty`, a non-continuable `tool-subagent`) resolve the registry with `ctx.get`, which an entry-local realm hides from them, so it is composed on the host plane and only the model-facing `tool-tasks` row stays per preset.
 - Preset ymls are unchanged: one mount per preset = one Entry per preset, whose entry-local realms (`isolate: <name>: true`) keep two presets' same-named services apart exactly as they kept two sessions' apart.
 - A shared realm label was NOT an option: `provide()` throws on a second registration under the same realm symbol, so labels pool the REALM, never the instance — a per-session world sharing a label crashes the second mount.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md

@@ -16,7 +16,7 @@ Status: implemented
 
 常驻挂载修的是这一类问题而非其中的个例:读取方需要的注册在进程生命周期内始终存在,按 preset id 索引,不需要任何 agent。让它便宜的原因:
 
-- 有状态的 preset 插件(`plan-mode`、`token-meter`、`compact-basic`、`tasks-local`)本就按 `Session`/`Agent` 分键存状态——它们早于 preset 存在。共享一份实例是回归其设计,不是改写。
+- 有状态的 preset 插件(`plan-mode`、`token-meter`、`compact-basic`)本就按 `Session`/`Agent` 分键存状态——它们早于 preset 存在。共享一份实例是回归其设计,不是改写。`tasks-local` 同样具备该性质,且此后已完全离开 preset 平面:realm 之外的生产方(`tool-bash`、`tool-pty`、非 continuable 的 `tool-subagent`)以 `ctx.get` 解析该注册表,而 entry-local realm 对它们不可见,因此它组合在宿主平面,只有面向模型的 `tool-tasks` 行仍留在各 preset 中。
 - preset 的 yml 不变:每 preset 挂一次 = 每 preset 一个 Entry,其 entry 本地 realm(`isolate: <name>: true`)让两个 preset 的同名服务互不相干,正如它从前隔开两个会话。
 - 共享 realm label **不是**选项:`provide()` 对同一 realm 符号下的第二次注册直接抛错,label 池化的是 REALM 而非实例——按会话挂载的世界里共享 label 会让第二次挂载崩溃。
 

+ 2 - 2
docs/config-catalog.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 docs/config-catalog.md
-config-catalog.md: 18980d22c694647374b9fa4e6dfbf245ff2416c4
-config-catalog.zh.md: a43c561806498ca53a95af815d0cd7686a0100ca
+config-catalog.md: f4d275393dd918f1391d33db19222e4e62e80b96
+config-catalog.zh.md: 9b3cf689c8d4c23d1cca1260b7e74c911558d678

+ 1 - 1
docs/config-catalog.md

@@ -2332,7 +2332,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts)
+Source: [`packages/tasks/tool-tasks/src/index.ts:24`](../packages/tasks/tool-tasks/src/index.ts)
 
 ## `@deepseek-ai/dsh-tool-todo`
 

+ 1 - 1
docs/config-catalog.zh.md

@@ -2333,7 +2333,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts)
+来源:[`packages/tasks/tool-tasks/src/index.ts:24`](../packages/tasks/tool-tasks/src/index.ts)
 
 ## `@deepseek-ai/dsh-tool-todo`
 

+ 2 - 2
docs/module-graph.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 docs/module-graph.md
-module-graph.md: c41db02165740b19a9ef751e6f50316a76df28c3
-module-graph.zh.md: 9071dbc0f2e6df8ec7edd1f3e14cd7ff063123fa
+module-graph.md: 0f76bfd2dd700d81e2c6fb6faec3d2c0c9655e98
+module-graph.zh.md: 7fa0eb72666e63e72109a272cdc9ce323c60d2fd

+ 2 - 1
docs/module-graph.md

@@ -947,6 +947,7 @@ flowchart TD
   pkg_tool_tasks --> pkg_invariants
   pkg_tool_tasks --> pkg_llm
   pkg_tool_tasks --> pkg_retention
+  pkg_tool_tasks --> pkg_scope
   pkg_tool_tasks --> pkg_system_prompt
   pkg_tool_tasks --> pkg_tasks
   pkg_tool_tasks --> pkg_tools
@@ -1390,7 +1391,7 @@ flowchart TD
 | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
 | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
 | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
-| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
+| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
 | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
 | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |

+ 2 - 1
docs/module-graph.zh.md

@@ -949,6 +949,7 @@ flowchart TD
   pkg_tool_tasks --> pkg_invariants
   pkg_tool_tasks --> pkg_llm
   pkg_tool_tasks --> pkg_retention
+  pkg_tool_tasks --> pkg_scope
   pkg_tool_tasks --> pkg_system_prompt
   pkg_tool_tasks --> pkg_tasks
   pkg_tool_tasks --> pkg_tools
@@ -1392,7 +1393,7 @@ flowchart TD
 | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
 | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
 | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
-| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
+| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
 | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
 | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |

+ 2 - 2
packages/tasks/tool-tasks/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/tasks/tool-tasks/README.md
-README.md: 6e8e889c2330d6991cb384674b011e4d2e988268
-README.zh.md: 355b6736b476fb2434f17ea3857544f32c40adb3
+README.md: 1b63ba7124e9bdbfbf64d70e36e90d1ff13a27c8
+README.zh.md: 946ba9156c4a9d8902f8c47deb6056b2f6525f86

+ 2 - 0
packages/tasks/tool-tasks/README.md

@@ -20,6 +20,8 @@ When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill`
 
 An unreported completion injects `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` into the exact owner's next-step inbox. When bounded, the stable id prefix and collection command outrank variable label/detail so the notice remains actionable at PTY's supported 64-byte minimum. Injection is durable pending context for a later pre-step claim, not a wake-up; cancellation or owner disposal may discard it before claim. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice.
 
+One host registry may carry several mounts of this plugin — one per agent preset — and the registry broadcasts each settlement to every mount. A scoped mount delivers only to owners composed under its own scope, so an agent reads exactly one notice per completion however many presets are mounted; an unscoped mount is the host-plane instance and delivers to every owner.
+
 ## Config
 
 | key | default | meaning |

+ 2 - 0
packages/tasks/tool-tasks/README.zh.md

@@ -20,6 +20,8 @@
 
 一项尚未报告的完成会把 `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` 注入到确切所有者的 next-step inbox。应用上限时,即使采用 PTY 支持的 64 字节下限,稳定 id 前缀和收集命令的优先级也高于可变 label/detail,因此通知仍可操作。注入是等待后续 pre-step 领取的持久上下文,并非唤醒;取消或 owner 释放可能在领取前丢弃它。kill 或针对已终止任务的 read/wait 会把交付标为已报告,并抑制重复通知。
 
+一个宿主注册表可能承载本插件的多份挂载——每个 agent preset 一份——而注册表会把每次结算广播给全部挂载。带 scope 的挂载只向在其自身 scope 下组合出的所有者交付,因此无论挂载了多少 preset,一个 agent 每次完成都只读到一条通知;不带 scope 的挂载是宿主平面实例,向每个所有者交付。
+
 ## 配置
 
 | key | 默认值 | 含义 |

+ 2 - 0
packages/tasks/tool-tasks/package.json

@@ -32,6 +32,7 @@
     "@deepseek-ai/dsh-invariants": "^0.0.1",
     "@deepseek-ai/dsh-llm": "^0.0.1",
     "@deepseek-ai/dsh-retention": "^0.0.1",
+    "@deepseek-ai/dsh-scope": "^0.0.1",
     "@deepseek-ai/dsh-system-prompt": "^0.0.1",
     "@deepseek-ai/dsh-tasks": "^0.0.1",
     "@deepseek-ai/dsh-tools": "^0.0.1",
@@ -42,6 +43,7 @@
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-retention": "workspace:^",
+    "@deepseek-ai/dsh-scope": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-tasks": "workspace:^",

+ 11 - 1
packages/tasks/tool-tasks/src/index.ts

@@ -10,6 +10,7 @@ import type { Context } from 'cordis'
 import z from 'schemastery'
 import { boundContextSummary, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
 import { TextRetainer } from '@deepseek-ai/dsh-retention'
+import { scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope'
 import { defineTool } from '@deepseek-ai/dsh-tools'
 import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
 import { TaskId } from '@deepseek-ai/dsh-tasks'
@@ -226,12 +227,21 @@ export function apply(ctx: Context, config: Config): void {
     text: 'Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task\'s work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.',
   })
 
-  // Use the exact lifecycle owner; reusable ids could resolve to a replacement.
   // Delivery targets the exact lifecycle owner. The notice waits in its
   // next-step inbox until another step claims it; disposal before that
   // boundary discards it with the owner.
+  //
+  // One host registry can carry SEVERAL mounts of this plugin — one per agent
+  // preset — and `settle()` broadcasts a single snapshot to every registered
+  // listener with no scope filter of its own. Each mount must therefore claim
+  // only the owners composed under it, or every mounted preset injects the
+  // same completion into the same agent and the model reads N copies of one
+  // notice. An unscoped mount is the host-plane instance that serves every
+  // agent, so it claims all of them.
+  const mountScope = scopeOf(ctx)
   ctx.tasks.onTaskDone((snapshot, owner) => {
     if (snapshot.reported || owner === undefined) return
+    if (mountScope !== undefined && !scopeChainOf(scopeOf(owner.ctx)).includes(mountScope)) return
     owner.inject(createUserMessage({
       content: [{
         type: 'text',

+ 49 - 0
packages/tasks/tool-tasks/tests/tool-tasks.spec.ts

@@ -6,6 +6,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import { SessionId } from '@deepseek-ai/dsh-session'
+import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
 import { TaskId } from '@deepseek-ai/dsh-tasks'
 import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
 import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
@@ -445,6 +446,54 @@ describe('tool-owned UI presentation (presentCall)', () => {
   })
 })
 
+describe('completion notices across scoped mounts', () => {
+  /**
+   * Two agent presets mounting `tool-tasks` over ONE host registry: each mount
+   * registers its own `onTaskDone` listener on the shared service, and
+   * `settle()` broadcasts one snapshot to every listener with no scope filter.
+   * Only the mount whose scope the owner belongs to may deliver the notice.
+   */
+  it('delivers one notice from the owning scope when two mounts share the registry', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(LocalTaskService)
+
+    const standingA = createScope(ctx, {})
+    const standingB = createScope(ctx, {})
+    await standingA.ctx.plugin(ToolTasks)
+    await standingB.ctx.plugin(ToolTasks)
+
+    // The agent joins preset A exactly as `agentPresets.compose` binds it.
+    const agentKey = {}
+    const agentScope = createScope(ctx, agentKey)
+    bindScopeParent(agentKey, scopeOf(standingA.ctx) as object)
+
+    const inject = vi.fn()
+    const owner = {
+      id: SessionId('sess-scoped'),
+      ctx: agentScope.ctx,
+      inject,
+      session: { id: SessionId('sess-scoped'), header: { version: 0, id: SessionId('sess-scoped'), createdAt: 0 } },
+    } as unknown as Agent
+    const dispose = ctx.agents.register(owner)
+
+    try {
+      // No waiter: `settle()` leaves `reported` false, which is the only path
+      // that reaches the notice listeners at all.
+      const p = producer({ owner, label: 'pnpm test' })
+      ctx.tasks.start(p.spec)
+      p.settle({ status: 'completed', detail: 'exit code: 0' })
+      await tick()
+
+      expect(inject).toHaveBeenCalledTimes(1)
+    } finally {
+      dispose()
+    }
+  })
+})
+
 describe('completion notices', () => {
   it('injects a notice into the owning agent when an unreported task settles', async () => {
     const { ctx } = await setup()

+ 3 - 0
packages/tasks/tool-tasks/tsconfig.json

@@ -26,6 +26,9 @@
     {
       "path": "../../core/system-prompt"
     },
+    {
+      "path": "../../core/scope"
+    },
     {
       "path": "../../core/tools"
     },

+ 3 - 0
pnpm-lock.yaml

@@ -6900,6 +6900,9 @@ importers:
       '@deepseek-ai/dsh-retention':
         specifier: workspace:^
         version: link:../../util/retention
+      '@deepseek-ai/dsh-scope':
+        specifier: workspace:^
+        version: link:../../core/scope
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session