Files
deepseek-harness/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md
Yichen Jiang 37ebe87087 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
2026-08-10 15:27:10 +08:00

4.5 KiB
Raw Blame History

Agent Note: Per-preset standing mounts over a scope parent chain

Status: implemented

English | 中文

Problem

按会话挂载 preset 让面向模型的注册面变成按 agent 的,而三个独立的宿主读取方仍然假设它是静态的:冷读 session.history 找不到 presenter每张卡都静默退化成通用渲染器——与「工具本无 presenter」无法区分、投影块丢掉 preset 注册的键(客户端把缺失键当作能力不存在并清掉该行、TypeRT 网关在宿主根上解析 goalsservice-unavailable)。逐个读取方打补丁只是拿一种静默降级换另一种:为拿到 presenter 而 resume会把投影折叠从 detached 翻到 livetoken 计数随之被抹掉。

Decision

一个 preset 是每进程一份组装而不是每会话一份。roster 在一个合成常驻 scope 下挂载它一次;每个 agent 通过把自己的 scope key 绑定到挂载的 keybindScopeParent(agentKey, standingKey))加入。两条 dsh-scope 机制承载了一切:注册视图沿父链解析(agent → preset → global,近者遮蔽远者),带作用域的分发对标签为载体键祖先的监听器放行——只向上,兄弟 preset 的监听器保持失聪。

Consequences

常驻挂载修的是这一类问题而非其中的个例:读取方需要的注册在进程生命周期内始终存在,按 preset id 索引,不需要任何 agent。让它便宜的原因

  • 有状态的 preset 插件(plan-modetoken-metercompact-basic)本就按 Session/Agent 分键存状态——它们早于 preset 存在。共享一份实例是回归其设计,不是改写。tasks-local 同样具备该性质,且此后已完全离开 preset 平面realm 之外的生产方(tool-bashtool-pty、非 continuable 的 tool-subagent)以 ctx.get 解析该注册表,而 entry-local realm 对它们不可见,因此它组合在宿主平面,只有面向模型的 tool-tasks 行仍留在各 preset 中。
  • preset 的 yml 不变:每 preset 挂一次 = 每 preset 一个 Entry其 entry 本地 realmisolate: <name>: true)让两个 preset 的同名服务互不相干,正如它从前隔开两个会话。
  • 共享 realm label 不是选项:provide() 对同一 realm 符号下的第二次注册直接抛错label 池化的是 REALM 而非实例——按会话挂载的世界里共享 label 会让第二次挂载崩溃。

Load-bearing details

  • 常驻挂载挂在服务未追踪的 selfCtx 上。 经 traceable 代理调用的方法看到的 this.ctx 被重绑到调用方并携带 shadow从它派生的子树里每个 fiber 的 reflect 解析都从 shadow 的 fiber 起步entry 会在自己 inject 声明的服务上失败(cannot get property "tools" without inject,而它的 store 里明明有)。tasks-local 的 selfCtx 先例,如今有了第二个消费者。
  • 挂载一旦成功即持续供职,直到组装文件的 stamp 变化。 运行中会话加入的组装必须在其文件被修改或删除后继续存活;每个代际记录文件 stampmtime + 大小),发现过期的会话开启下一个代际,因此文件编辑——创作改为仅复制之后唯一的组装编辑器——无需任何创作调用丢弃指针即可达到后续会话。已加入的会话保持其代际,被替代的代际只由整树卸载回收——刻意为之,上限取决于编辑频率,已记入包的 Known Limitations。
  • peek() 保持不看链。 限制与守卫定位的是单个作用域自己的贡献;只有注册视图沿链继承。链上的限制求交(链上任一作用域都可为嵌套其内的一切遮蔽某个全局面名字)。
  • 重新认父只能经由挂载首绑返回的 ScopeParentBinding——roster 私藏该句柄,空白会话 recompose 因此是唯一的重链路径,其他调用方无法挪动已组合的 agent其合法性仍以旧父之下产出一概不被保留为前提由持有方保证因为该关系看不见会话日志。

Alternatives considered

冷读时 resume抹掉 detached 投影)、宿主面 presenter 表加投影块完整性标志(修两个读取方、留下这一类)、每会话模板挂载(为了服务纯函数而复制每一份实例)。留档:面向网关的 goals 域无论如何留在宿主平面——Remote 方法的接收者来自生成的 descriptor、在宿主上解析这正是 bash-env 宿主平面判据从消费侧读出的样子。