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
4.5 KiB
Agent Note: Per-preset standing mounts over a scope parent chain
Status: implemented
English | 中文
Problem
按会话挂载 preset 让面向模型的注册面变成按 agent 的,而三个独立的宿主读取方仍然假设它是静态的:冷读 session.history 找不到 presenter(每张卡都静默退化成通用渲染器——与「工具本无 presenter」无法区分)、投影块丢掉 preset 注册的键(客户端把缺失键当作能力不存在并清掉该行)、TypeRT 网关在宿主根上解析 goals(service-unavailable)。逐个读取方打补丁只是拿一种静默降级换另一种:为拿到 presenter 而 resume,会把投影折叠从 detached 翻到 live,token 计数随之被抹掉。
Decision
一个 preset 是每进程一份组装,而不是每会话一份。roster 在一个合成常驻 scope 下挂载它一次;每个 agent 通过把自己的 scope key 绑定到挂载的 key(bindScopeParent(agentKey, standingKey))加入。两条 dsh-scope 机制承载了一切:注册视图沿父链解析(agent → preset → global,近者遮蔽远者),带作用域的分发对标签为载体键祖先的监听器放行——只向上,兄弟 preset 的监听器保持失聪。
Consequences
常驻挂载修的是这一类问题而非其中的个例:读取方需要的注册在进程生命周期内始终存在,按 preset id 索引,不需要任何 agent。让它便宜的原因:
- 有状态的 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 会让第二次挂载崩溃。
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 变化。 运行中会话加入的组装必须在其文件被修改或删除后继续存活;每个代际记录文件 stamp(mtime + 大小),发现过期的会话开启下一个代际,因此文件编辑——创作改为仅复制之后唯一的组装编辑器——无需任何创作调用丢弃指针即可达到后续会话。已加入的会话保持其代际,被替代的代际只由整树卸载回收——刻意为之,上限取决于编辑频率,已记入包的 Known Limitations。
peek()保持不看链。 限制与守卫定位的是单个作用域自己的贡献;只有注册视图沿链继承。链上的限制求交(链上任一作用域都可为嵌套其内的一切遮蔽某个全局面名字)。- 重新认父只能经由挂载首绑返回的
ScopeParentBinding——roster 私藏该句柄,空白会话 recompose 因此是唯一的重链路径,其他调用方无法挪动已组合的 agent;其合法性仍以旧父之下产出一概不被保留为前提,由持有方保证,因为该关系看不见会话日志。
Alternatives considered
冷读时 resume(抹掉 detached 投影)、宿主面 presenter 表加投影块完整性标志(修两个读取方、留下这一类)、每会话模板挂载(为了服务纯函数而复制每一份实例)。留档:面向网关的 goals 域无论如何留在宿主平面——Remote 方法的接收者来自生成的 descriptor、在宿主上解析,这正是 bash-env 宿主平面判据从消费侧读出的样子。