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
+11 -1
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',