DSH / Atlas
2026-08-26implementedarchitecturearchived 2026-09-04

Preset health resolves the rows it can prove will start

Preset health resolves the rows it can prove will start

A preset the roster listed as healthy could still be impossible to compose. Discovery's health check proved the composition parsed in the loader dialect and held named rows, and deliberately stopped there — it resolved no plugin name and applied no config. This note partly supersedes [broken presets are roster rows](../bug-fix/2026-08-09-broken-preset-roster-rows.md), whose rejected "validating deep" alternative is w

English

Problem

A preset the roster listed as healthy could still be impossible to compose. Discovery's health check proved the composition parsed in the loader dialect and held named rows, and deliberately stopped there — it resolved no plugin name and applied no config.

This note partly supersedes broken presets are roster rows, whose rejected "validating deep" alternative is what shipped here, and it moved the reason off the card face; it also relaxes the shipped-roster assertion plugin-owned shipped preset root records. Both are updated in place.

broken is load-bearing, though, not a card decoration. presetOptions drops a broken row from the session pickers so a chooser never defers the discovery to a failed session start, and resolveMountable refuses one before spending a mount. Everything downstream therefore reads "not broken" as "will compose".

The gap surfaced when the repository naming contract renamed packages under the pre-release stance. In-repo references moved with it; a preset authored under <dshHome>/.agent-presets did not, and one naming @deepseek-ai/dsh-workspace-context kept its healthy card, kept its place in the picker, and failed only when a person switched to it. A row naming a package a later release renamed or uninstalled is how an authored preset actually rots, and it was exactly the class the check excluded.

The failure it did produce named less than it knew. The Loader's per-row wrapper builds a plain Error whose message ends with cause.message and keeps the cause only as error.cause. A group that fails on two rows therefore arrives as one wrapped row whose message is failed to apply loader entry <group> (cordis:group): loader entries failed to apply, with the two real reasons reachable through cause.errors alone. The mount diagnostic flattened AggregateError.errors and never followed cause, so it ended at that line and named neither row.

Decision

Discovery resolves each row it can prove will start, and imports nothing. The resolve pass runs after the shape check in packages/preset/agent-presets/src/discovery.ts, so a malformed composition still answers with the shape reason. A package name is looked up on disk — Node's own upward node_modules walk, stopping at <package>/package.json. A preset-relative or absolute specifier is statted instead, because import.meta.resolve only joins URLs for those and a preset shipping a file that was deleted would otherwise pass. Nothing is evaluated either way.

The disk lookup, not import.meta.resolve, for two reasons. It is the cheap one: a registered ESM loader hook turns every resolver call into a synchronous round-trip to the hooks thread, measured under the tsx hook the source launch installs at 2ms for a hit and 5ms for a miss against 0.055ms and 0.032ms on bare Node — 238ms of resolver time per roster read, where the walk answers the same 135 rows in 0.7ms. It is also the only one that can be asked about the harness at all: import.meta.resolve's parentURL argument takes effect only under --experimental-import-meta-resolve, which no launch passes, so it resolves from the calling module and would answer about this package rather than about the deployment. The resolver that does honour an explicit parent is the Loader's internal one, whose resolveSync signature differs between Node 22 and 24. A Node builtin short-circuits ahead of the walk.

What the walk gives up: a package resolvable only through a loader hook — an import map, or a tree with no node_modules — is reported broken. No supported install produces one, because dsh plugin install puts every plugin beside the roster.

One classifier decides where a row resolves. src/specifier.ts owns the split — cordis: builtin, preset-relative, absolute file, package name — and both the mount's import override and discovery's check read it. A row discovery resolved from one base while the mount imported it from another would be reported healthy and then fail to load.

A row that may never start is skipped. disabled is the one entry field the Loader interpolates: a !!js expression evaluates against the loader context at mount time, which discovery cannot do from a file. A row carrying anything but an absent, null, or false value is left unchecked, and a disabled group takes its children with it. Every shipped preset gates its shell rows this way, so this is the common shape, not a corner.

The harness base is a required argument. discoverPresets(roots, harnessBase) and scanRoot(root, harnessBase) take it; AgentPresets reads ctx.baseUrl once in its constructor and throws when it is absent. The base is what makes the question answerable at all — the same package name fails from a preset's own directory and resolves from the installed harness — so an optional one would silently restore the state this check exists to end.

The mount diagnostic follows a cause that carries more than its message. mountDetail reads branches from AggregateError.errors, or from error.cause.errors when the cause is an AggregateError; a plain cause chain is already flattened into the message and is not followed, which would print every line twice. Nested branches indent under the row that owns them.

The client puts the reason on the badge. The card face keeps the preset's own description, because a package specifier tells a chooser nothing they can act on there. The host's reason is revealed by hovering the badge or focusing the card, and a visually hidden role="alert" node announces it. A broken card says so through aria-disabled rather than disabled and refuses the pick in its own handler: disabled would take it out of the tab order, and with the reason off the face that would leave anyone without a pointer unable to reach it at all.

A refused switch says why, where it was refused. The chip's own label reverts to the preset the session still runs, so without a word the pick simply appears not to have happened. It announces through the shared Toast, over the composer column, the way the model picker beside it already reports a rejected selection. Only a pick a person just made is announced — the applier also runs when a session becomes current, and a banner over that would report a refusal nobody asked for. The banner holds for eight seconds rather than the primitive's three, because it carries a cause that names packages and rows; Toast gained a holdMs for that, which also retired the hazard of a hold constant the stylesheet had to be kept in step with by hand.

The wire already separated the two texts this needs: message wraps the cause in the roster's own "preset X failed to mount" frame, while details.reason holds the cause alone. A surface that names the preset itself takes the second, or it says the preset twice.

Alternatives considered

Check when a preset is selected rather than when the roster is listed. Rejected. The pickers filter on broken before anyone selects, so a preset only checked at selection is still offered, and the reported failure still arrives after the click — the original complaint, relocated. The roster row is where every consumer already reads the verdict.

Keep the base optional and skip the check without one. Rejected. Its failure mode is precisely the bug being fixed, delivered with no signal: healthy cards for presets that cannot compose. ctx.baseUrl is set on the root before any scoped context derives from it, so the throw is an assertion about something that does not happen rather than a branch with runtime cost.

Import each row instead of resolving it. Rejected. Importing runs module top-level code on every roster read, which is a side effect a picker must not have, and it is the mount's job — a plugin that throws on apply or waits forever for a service still fails at the first session, by design.

Resolve every row through import.meta.resolve. Shipped first and reverted on measurement: correct, and 445ms per roster read, which the client's three concurrent reads turned into 2.45 seconds apiece — the settings section visibly stalled. The resolver is the authority on what imports, but asking it about rows that are plainly installed pays a hooks-thread round-trip for each one.

Cache the whole of compositionProblem on the existing CompositionStamp. Rejected as the answer to the cost: it would have made repeat reads free while leaving the first read of every edited composition at full price, and it keys resolution on the composition file, which does not change when an install does. The walk removed the cost instead, so nothing needs the stamp.

Send the switch failure to the roster card instead of a banner. Rejected: the card is exactly where the failures that reach a mount are invisible. A composition whose rows all resolve is reported healthy, so "see the settings page for the reason" points at a card that says the preset is fine.

Report only the first unresolvable row, matching the shape check. Rejected. A parse failure can cascade, so naming one is honest there; unresolvable names are independent facts all knowable at once, and reporting them one reload at a time is the avoidable part.

Follow error.cause unconditionally in mountDetail. Rejected. The Loader's wrapper already appends cause.message to the message it builds, so a plain chain would render every line twice. An AggregateError cause is the one shape whose detail the message drops.

Keep rendering the reason on the card face. Rejected. The reason names package specifiers and paths, and a picker card that shows them in place of the preset's description trades what a chooser needs for what a fixer needs — while the fixer's copy is one hover away either way.

Reuse the icon row's data-tip pseudo-element for the tooltip. Rejected once measured: generated content joins an element's accessible text, so the card's aria snapshot grew a second verbatim copy of a reason the alert already carried. A real aria-hidden element keeps exactly one accessible copy — and the existing tooltip is one nowrap line sized for an icon label, while this one names package specifiers one per line.

Make the badge itself the focusable control. Rejected: the badge sits inside the card's own <button>, so a focusable trigger there means restructuring the card head. Keeping the card focusable through aria-disabled reveals the same tooltip from the same key press and changes no layout. Leaving the reason reachable by pointer alone was rejected too — it was visible without any interaction before this change, so hiding it behind hover is a regression for anyone reading by keyboard rather than a path that never existed.

Consequences

A preset naming a package that a rename or an uninstall took away is marked on the roster, refused before a mount is spent, and dropped from the pickers — the same treatment a ghost directory already got. The reason names each row at fault, and a failure that survives to mount names every row inside a group rather than the group alone.

Health answers from what is installed, not from what would import: a package present but exporting a file that is missing still reports healthy and still fails at mount. That is the safe direction — under-reporting returns the previous behavior, while a false broken makes a usable preset unselectable — and it keeps the answer out of the build state of any one package. A source checkout is still not an installed host, though, because a shipped row names a package the deployment installs beside the roster: shipped-root.spec.ts asserts the shipped presets carry no reason other than unresolved rows rather than no reason at all. The mount fixtures name a module that loads and then refuses, since a fixture naming a file that does not exist can no longer reach the mount.

A mount failure is now legible where it happens, which matters most for the failures health can never catch: a row that resolves and then refuses is reported healthy on the roster forever, so the banner is not a convenience over the card — it is the only account of that failure anywhere.

Measured in the web app on a roster of eleven presets, agentPreset.list answers in 14ms cold and 6-8ms after, and the three concurrent reads the client opens with settle in 9ms of wall clock. The same reads took 2.45 seconds each while every row went through the resolver.

@deepseek-ai/cordis-plugin-group is a devDependency of dsh-agent-presets: the mount fixtures now compose through cordis:group the way real presets do, and a preset outside the workspace cannot resolve that package by name, so the app registers it as a builtin and the fixture harness does the same.

中文

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 也照做。