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
This commit is contained in:
Yichen Jiang
2026-08-10 15:27:10 +08:00
parent 02d45ed004
commit 37ebe87087
17 changed files with 88 additions and 15 deletions

View File

@@ -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

View File

@@ -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 |

View File

@@ -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 | 默认值 | 含义 |

View File

@@ -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:^",

View File

@@ -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',

View File

@@ -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()

View File

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