Per-preset standing mounts over a scope parent chain
基于作用域父链的逐预设常驻挂载
Per-session preset mounts made the model-facing registry surface per-agent while three independent host readers still assumed it was static: cold `session.history` found no presenters (every card silently degraded to the generic renderer — indistinguishable from "tool has no presenter"), the projections block dropped preset-registered keys (clients treat an omitted key as capability absence and CLEAR the row), and th
English
Problem
Per-session preset mounts made the model-facing registry surface per-agent while three independent host readers still assumed it was static: cold session.history found no presenters (every card silently degraded to the generic renderer — indistinguishable from "tool has no presenter"), the projections block dropped preset-registered keys (clients treat an omitted key as capability absence and CLEAR the row), and the Typert gateway resolved goals on the host root (service-unavailable). Patching each reader individually traded one silent degradation for another: resuming to reach presenters flipped the projections fold from detached to live and wiped the token counts instead.
Decision
A preset is one composition per PROCESS, not one per session. The roster mounts it once under a synthetic standing scope; each agent joins by binding its scope key to the mount's (bindScopeParent(agentKey, standingKey)). Two dsh-scope mechanisms carry everything: registration views walk the parent chain (agent → preset → global, nearest shadowing farthest), and scoped dispatch admits listeners tagged with an ancestor of the carrier key — upward only, so a sibling preset's listeners stay deaf.
Consequences
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,compaction-basic) already key state bySession/Agent— they predate presets. Sharing one instance is a return to their design, not a rewrite.jobs-localshared that property and has since left the preset plane entirely: producers outside its realm (tool-bash,tool-terminal, a non-continuabletool-subagent) resolve the registry withctx.get, which an entry-local realm hides from them, so it is composed on the host plane and only the model-facingtool-jobsrow 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.
Load-bearing details
- Standing mounts hang off the service's untraced
selfCtx. A method invoked through the traceable proxy seesthis.ctxrebound to the caller with a shadow; reflect resolution for every fiber in a subtree minted from it starts at the shadow's fiber, so entries fail on services their owninjectdeclares (cannot get property "tools" without injectwhile the entry's store holds it). Thejobs-localselfCtx precedent, now with a second consumer. - A settled mount serves until its composition file's stamp changes. The composition a running session joined must survive its file changing or disappearing; each generation records the file's stamp (mtime + size) and a session that finds it stale starts the next generation, so file edits — the only composition editor once authoring became copy-only — reach later sessions without any authoring call dropping the pointer. Joined sessions keep their generation, and superseded generations are reclaimed only by whole-tree teardown — deliberate, bounded by edit frequency, recorded in the package's Known Limitations.
peek()stays chain-blind. Restrictions and guards address one scope's own contributions; only registration VIEWS inherit. Restrictions along the chain intersect (any scope may mask a globally registered name for everything nested inside it).- Re-linking runs only through the
ScopeParentBindingthe mount's one bind returned — the roster holds it privately, so the blank-session recompose path is the sole re-link and no other caller can move a composed agent; it stays valid only while nothing produced under the old parent is retained, which the holder must uphold because the relation cannot see session logs.
Alternatives considered
Resume-on-read (wipes detached projections), a host-plane presenter table plus a block completeness flag (fixes two readers, leaves the class), per-session template mounts (duplicates every instance to serve pure functions). Kept for the record: the gateway-facing goals domain stays host-plane regardless — a Remote method whose receiver comes from a generated descriptor resolves on the host, which is the shell-env host-plane criterion read from the consuming side.
中文
问题
按会话挂载 preset 让面向模型的注册视图变成按 agent 的,而三个独立的宿主读取方仍然假设它是静态的:冷读 session.history 找不到 presenter(每张卡都静默退化成通用渲染器——与「工具本无 presenter」无法区分)、投影块丢掉 preset 注册的键(客户端把缺失键当作能力不存在并清掉该行)、Typert 网关在宿主根上解析 goals(service-unavailable)。逐个读取方打补丁只是拿一种静默降级换另一种:为拿到 presenter 而 resume,会把投影折叠从 detached 翻到 live,token 计数随之被抹掉。
决策
一个 preset 是每进程一份组装,而不是每会话一份。roster 在一个合成常驻 scope 下挂载它一次;每个 agent 通过把自己的 scope key 绑定到挂载的 key(bindScopeParent(agentKey, standingKey))加入。两条 dsh-scope 机制承载了一切:注册视图沿父链解析(agent → preset → global,近者遮蔽远者),带作用域的分发对标签为载体键祖先的监听器放行——只向上,兄弟 preset 的监听器保持失聪。
后果
常驻挂载修的是这一类问题而非其中的个例:读取方需要的注册在进程生命周期内始终存在,按 preset id 索引,不需要任何 agent。让它便宜的原因:
- 有状态的 preset 插件(
plan-mode、token-meter、compaction-basic)本就按Session/Agent分键存状态——它们早于 preset 存在。共享一份实例是回归其设计,不是改写。jobs-local同样具备该性质,且此后已完全离开 preset 平面:realm 之外的生产方(tool-bash、tool-terminal、非 continuable 的tool-subagent)以ctx.get解析该注册表,而 entry-local realm 对它们不可见,因此它组合在宿主平面,只有面向模型的tool-jobs行仍留在各 preset 中。 - preset 的 yml 不变:每 preset 挂一次 = 每 preset 一个 Entry,其 entry 本地 realm(
isolate: <name>: true)让两个 preset 的同名服务互不相干,正如它从前隔开两个会话。 - 共享 realm label 不是选项:
provide()对同一 realm 符号下的第二次注册直接抛错,label 池化的是 REALM 而非实例——按会话挂载的世界里共享 label 会让第二次挂载崩溃。
承重细节
- 常驻挂载挂在服务未追踪的
selfCtx上。 经 traceable 代理调用的方法看到的this.ctx被重绑到调用方并携带 shadow;从它派生的子树里每个 fiber 的 reflect 解析都从 shadow 的 fiber 起步,entry 会在自己inject声明的服务上失败(cannot get property "tools" without inject,而它的 store 里明明有)。jobs-local的 selfCtx 先例,如今有了第二个消费者。 - 挂载一旦成功即持续供职,直到组装文件的 stamp 变化。 运行中会话加入的组装必须在其文件被修改或删除后继续存活;每个代际记录文件 stamp(mtime + 大小),会话发现当前代际已陈旧时,会开启下一个代际,因此文件编辑——创作改为仅复制之后唯一的组装编辑器——无需任何创作调用丢弃指针即可达到后续会话。已加入的会话保持其代际,被替代的代际只由整树卸载回收——刻意为之,上限取决于编辑频率,已记入包的 Known Limitations。
peek()保持不看链。 限制与守卫定位的是单个作用域自己的贡献;只有注册视图沿链继承。链上的限制求交(链上任一作用域都可为嵌套其内的一切遮蔽某个全局注册名称)。- 重新认父只能经由挂载首绑返回的
ScopeParentBinding——roster 私藏该句柄,空白会话 recompose 因此是唯一的重链路径,其他调用方无法挪动已组合的 agent;其合法性仍以旧父之下产出一概不被保留为前提,由持有方保证,因为该关系看不见会话日志。
考虑过的替代方案
冷读时 resume(抹掉 detached 投影)、宿主面 presenter 表加投影块完整性标志(修两个读取方、留下这一类)、每会话模板挂载(为了服务纯函数而复制每一份实例)。留档:面向网关的 goals 域无论如何留在宿主平面——Remote 方法的接收者来自生成的 descriptor、在宿主上解析,这正是 shell-env 宿主平面判据从消费侧读出的样子。