`import.meta.resolve`'s `parentURL` argument takes effect only under `--experimental-import-meta-resolve`, which no launch passes, so the fallback resolved from this module rather than from the harness — the one question it existed to answer. The disk walk is the whole answer now, and the refusal memo it needed goes with it. A `file:` URL joins the file branch rather than the package one, where a resolver would only normalize it and report a missing target as present, and a row is skipped on the Loader's own `Boolean(disabled)` so `disabled: 0` is checked like the Loader checks it. A broken card says so through `aria-disabled` rather than `disabled`, and refuses the pick in its own handler. `disabled` took it out of the tab order, which with the reason moved onto the badge left it unreachable without a pointer — reachable before this change, so hiding it was a regression rather than a path that never existed. Both notes this decision partly supersedes are updated in place and cross-linked, one README pair loses an editing residue that repeated a sentence, and the single-row diagnostic no longer reads "row row 1".
12 KiB
Agent Note: Preset health resolves the rows it can prove will start
Status: implemented
English | 中文
Problem
名单列为健康的 preset,仍可能根本无法组装。发现过程的健康检查只证明组装能以加载器方言解析、由具名行组成,并刻意止步于此——它不解析任何插件名,也不应用任何配置。
本 note 部分取代了损坏的 preset 是名单行:那份 note 在 Alternatives 中否决的「深度校验」正是这里落地的做法,而且原因也已移出卡片正面;它同时放宽了插件自带内置 preset 根记录的随附名单断言。两份都已就地更新。
但 broken 是承重的,不是卡片上的装饰。presetOptions 会把损坏的行从会话选择器里滤掉,好让选择的人不必等到会话启动失败才发现;resolveMountable 会在花费一次挂载之前拒绝它。因此下游一切都把「不是 broken」读作「能组装」。
这个缺口在仓库命名契约按预发布立场重命名包时暴露出来。仓库内的引用随之更新;写在 <dshHome>/.agent-presets 下的 preset 没有,于是引用 @deepseek-ai/dsh-workspace-context 的那一个保住了健康的卡片、保住了在选择器里的位置,直到有人切换过去才失败。引用了被后续版本改名或卸载的包,正是手写 preset 真正的腐化方式,而它恰好是这项检查排除掉的那一类。
而它真正产出的失败,说得比它知道的还少。加载器的逐行包装构造一个普通 Error,其 message 以 cause.message 结尾,cause 只留在 error.cause 上。于是一个有两行失败的 group,抵达时是一行被包装的行,message 为 failed to apply loader entry <group> (cordis:group): loader entries failed to apply,两条真正的原因只能经由 cause.errors 取得。挂载诊断只展平 AggregateError.errors,从不跟随 cause,因此它停在那一行,一行都没点名。
Decision
发现过程解析每一行它能证明会启动的行,且不 import 任何东西。 解析这一趟跑在 packages/preset/agent-presets/src/discovery.ts 的形状检查之后,因此格式错误的组装仍然回答形状原因。包名先在磁盘上查——就是 Node 自己那套向上走 node_modules、停在 <包>/package.json 的做法。preset 相对路径与绝对路径改用 stat,因为对这两类 import.meta.resolve 只做 URL 拼接,否则一个丢失了自带文件的 preset 会蒙混过关。两条路都不求值。
用磁盘查找而不是 import.meta.resolve,有两个理由。它便宜:只要注册了 ESM loader hook,每一次解析器调用就变成一次到 hooks 线程的同步往返,在源码启动所用的 tsx hook 下实测命中 2ms、未命中 5ms,而裸 Node 分别是 0.055ms 与 0.032ms——每次名单读取要背上 238ms 的解析器时间,而同样这 135 行磁盘走法只要 0.7ms。它也是唯一问得到「宿主」的:import.meta.resolve 的 parentURL 参数只在 --experimental-import-meta-resolve 下生效,而没有任何启动方式传它,因此它是相对调用方模块解析的,回答的是关于本包而不是关于部署的问题。真正认显式 parent 的是 Loader 的内部解析器,而它的 resolveSync 在 Node 22 与 24 上签名不同。Node 内建模块在磁盘查找之前直接短路。
磁盘走法放弃了什么:只有经由 loader hook 才能解析的包——import map,或根本没有 node_modules 的目录树——会被报为损坏。任何受支持的安装都不会产出这种情况,因为 dsh plugin install 会把每个插件装在名单旁边。
只有一个分类器决定一行在哪里解析。 src/specifier.ts 拥有这个划分——cordis: 内建、preset 相对、绝对文件、包名——挂载的 import 覆写与发现过程的检查都读它。若发现过程按一个基准解析、而挂载按另一个基准 import,那一行会被报告为健康,然后加载失败。
可能永远不会启动的行被跳过。 disabled 是加载器唯一会插值的条目字段:!!js 表达式在挂载时对加载器上下文求值,而发现过程无法仅凭文件做到。凡该字段不是缺失、null 或 false 的行都不做检查,被禁用的 group 连同其子行一起跳过。每个随附 preset 都用这种方式为 shell 行设门,所以这是常见形状,不是边角。
harness base 是必填参数。 discoverPresets(roots, harnessBase) 与 scanRoot(root, harnessBase) 都接收它;AgentPresets 在构造函数里读一次 ctx.baseUrl,缺失就抛。基准正是让这个问题可回答的前提——同一个包名从 preset 自己的目录解析会失败、从已安装的 harness 解析会成功——所以做成可选就等于悄悄恢复这项检查要终结的那个状态。
挂载诊断跟随携带信息多于自身 message 的 cause。 mountDetail 从 AggregateError.errors 取分支,或在 cause 是 AggregateError 时从 error.cause.errors 取;普通的 cause 链已被展平进 message,不再跟随,否则每一行都会打印两遍。嵌套分支在拥有它的那一行下缩进。
客户端把原因放到徽标上。 卡片正面保留 preset 自己的描述,因为在那里一个包说明符不足以让选择的人采取行动。宿主给出的原因在悬停徽标或聚焦卡片时展开,另有一个视觉隐藏的 role="alert" 节点负责朗读。损坏的卡片用 aria-disabled 而非 disabled 表达这件事,并在自己的处理函数里拒绝这次选择:disabled 会把它移出 tab 序列,而原因已不在正面,那等于让不用指针的人完全够不到它。
被拒绝的切换要在被拒绝的地方说明原因。 chip 的标签会弹回会话仍在运行的那个 preset,因此不说话的话,这次选择看起来就像根本没发生。它经由共享的 Toast 在 composer 列上方自报,与旁边的模型选择器报告被拒绝的选择方式一致。只有人刚做出的选择会被自报——应用器在会话成为当前会话时也会运行,为那种情况弹横幅等于报告一个没人问过的拒绝。横幅停留八秒而非 primitive 默认的三秒,因为它承载的原因要点名包与行;Toast 为此获得了 holdMs,顺带也消除了「停留常量必须由人手与样式表保持同步」这一隐患。
线上本来就把这需要的两段文本分开了:message 把原因裹进名单自己的「preset X failed to mount」框架,而 details.reason 只保留原因本身。自己会点名 preset 的表面取后者,否则会把 preset 说两遍。
Alternatives considered
在选中 preset 时检查,而不是在列出名单时。 否决。选择器在任何人选中之前就按 broken 过滤,因此只在选中时检查的 preset 仍会被摆出来,报出的失败仍在点击之后到达——原本的抱怨只是换了个位置。名单行才是每个消费者已经在读判定的地方。
让 base 可选,没有它就跳过检查。 否决。它的失败模式正是要修的这个 bug,而且不带任何信号:无法组装的 preset 顶着健康卡片。ctx.baseUrl 在任何作用域上下文派生之前就设在根上,因此这个抛出是对「不会发生的事」的断言,而不是一条有运行时代价的分支。
import 每一行而不是解析它。 否决。import 会在每次读取名单时执行模块顶层代码,这是选择器不该有的副作用,而且那是挂载的职责——在 apply 时抛错、或永远等待某个服务的插件仍按设计在第一个会话处失败。
每一行都交给 import.meta.resolve 解析。 先这样发出去,实测后回退:它是对的,但每次名单读取要 445ms,而客户端并发的三次读取把它放大成每次 2.45 秒——设置分区肉眼可见地卡住。解析器确实是「什么能 import」的权威,但为明摆着装好的行去问它,等于为每一行付一次 hooks 线程往返。
把整个 compositionProblem 缓存在已有的 CompositionStamp 上。 作为省开销的手段被否决:它只能让重复读取免费,每个被编辑过的组装的第一次读取仍是全价;而且它把解析结果挂在组装文件上,可安装状态变化时组装文件并不会变。改用磁盘查找直接消掉了开销,于是没有什么还需要这个 stamp。
把切换失败送到名单卡片上,而不是弹横幅。 否决:卡片恰恰是那些能走到挂载的失败看不见的地方。所有行都能解析的组装会被报告为健康,于是「去设置页看原因」指向的是一张写着「这个 preset 没问题」的卡片。
只报第一个无法解析的行,与形状检查保持一致。 否决。解析失败会连锁,所以在那里只点名一个是诚实的;无法解析的名字是彼此独立、一次即可全部知晓的事实,而一次重载只修一个才是可以避免的部分。
在 mountDetail 里无条件跟随 error.cause。 否决。加载器的包装已经把 cause.message 追加进它构造的 message,因此普通链会把每一行渲染两遍。AggregateError 类型的 cause 是唯一被 message 丢掉细节的形状。
继续把原因渲染在卡片正面。 否决。原因里是包说明符和路径,把它们摆在 preset 描述的位置,等于用选择者需要的东西换取修复者需要的东西——而修复者需要的那份,无论如何都只隔一次悬停。
复用图标行的 data-tip 伪元素来做提示条。 实测后否决:生成内容会并入元素的可访问文本,因此卡片的 aria 快照多出一份 alert 已经携带的原因的逐字副本。改用真实的 aria-hidden 元素后,可访问副本恰好只有一份——而且既有那条提示条是为图标标签准备的单行 nowrap,这一条要逐行列出包说明符。
把徽标本身做成可聚焦控件。 否决:徽标位于卡片自身的 <button> 内部,在那里放可聚焦触发器意味着改造卡片头部结构。用 aria-disabled 让卡片保持可聚焦,同一次按键就能展开同一条提示条,且不改动任何布局。「原因只用指针可达」同样被否决——这次改动之前它无需任何交互就可见,因此把它藏到悬停之后,对用键盘阅读的人是退化,而不是一条本就不存在的路径。
Consequences
引用了被改名或卸载掉的包的 preset,会在名单上被标出、在花费挂载之前被拒绝、并从选择器里剔除——与幽灵目录早已得到的待遇一致。原因会点名每一个出问题的行;而活到挂载阶段的失败,会点名 group 内的每一行,而不只是 group 本身。
健康依据的是「装没装」,而不是「能不能 import」:包在、但导出指向的文件不在,仍会报告健康并仍在挂载时失败。这是安全的方向——漏报只是退回先前的行为,而误报会让一个可用的 preset 变得不可选——而且它让答案不依赖于任何单个包的构建状态。不过源码检出仍不是已安装宿主,因为随附行引用的是部署与名单装在一起的包:shipped-root.spec.ts 断言随附 preset 除未解析行之外不携带其他原因,而不是不携带任何原因。挂载夹具改为引用一个能加载、随后拒绝的模块,因为引用不存在文件的夹具已经到不了挂载。
挂载失败现在在它发生的地方就可读,这对健康永远抓不到的那类失败最要紧:能解析、随后拒绝的行在名单上永远显示健康,因此这条横幅不是卡片之外的一份便利——它是那类失败在任何地方唯一的交代。
在 web 应用中对十一个 preset 的名单实测:agentPreset.list 冷启动 14ms、之后 6–8ms,客户端开场并发的三次读取合计 9ms wall。在每一行都过解析器的版本里,同样这三次读取各要 2.45 秒。
@deepseek-ai/cordis-plugin-group 成为 dsh-agent-presets 的 devDependency:挂载夹具现在像真实 preset 那样经由 cordis:group 组装,而工作区之外的 preset 无法按名解析该包,所以应用把它注册为内建,夹具 harness 也照做。