fix(agent-presets,web): broken presets are roster rows, not gaps
A hand-damaged preset was silent until the worst moment. An unparsable composition listed as an ordinary selectable row and failed only at the next session start — set as default, every new session failed. A directory whose composition file was deleted vanished from the roster while still occupying its id: copy answered "delete the existing preset first" while remove answered "not found", a dead end. Discovery now owns health: every id-shaped directory is a roster slot, broken when its composition is missing or unloadable, checked with the loader's own entryListSchema dialect (!!js included) so health never rejects what the loader accepts. `broken` rides AgentPreset, the agentPreset.list entry, and the UI row; mount/recompose/standingKeyFor refuse broken up front with the discovery-reported reason, while resolve/read/remove still answer. The section renders marked red cards — unselectable, uncopyable, deletable, location kept on custom rows — and both pickers drop broken rows entirely. The cordis preset's persona now forbids editing the shipped install (corrupting cordis would disable the mode itself) and points authoring at $DSH_HOME/.agent-presets; its skill teaches preset.yml metadata, the copy-first workflow, the one-escalation sandbox reality, and honest verification. Exercised live: asked to edit the shipped composition the composed agent refuses citing both rules; asked for real presets (simple and complex) it lands them under the user root with one approved escalation each and self-checks with the loader dialect.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md
|
||||
2026-08-09-broken-preset-roster-rows.md: fef6a183b10f98b8ae9d2b42701380c69bc83462
|
||||
2026-08-09-broken-preset-roster-rows.zh.md: 196bcf4ef16325a1d7692d2ea13d9fa683d500f4
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Broken presets are roster rows, not gaps
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-09-broken-preset-roster-rows.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
With files as the only composition editor, hand-edit damage had two failure shapes and both were silent until the worst moment. A preset whose `agent.cordis.yml` no longer parsed listed as a perfectly ordinary row — selectable, copyable, settable as the default — and failed only when the next session tried to mount it; set as default, every new session failed to start. A directory whose composition file was deleted outright vanished from the roster while still occupying its id on disk: `copy` refused the name with "delete the existing preset first" and `remove` answered "not found" — two contradictory errors with no way out short of hand-deleting the directory.
|
||||
|
||||
## Decision
|
||||
|
||||
Discovery owns health, and a damaged directory is a **roster row carrying a `broken` reason**, never a gap. `scanRoot` treats every directory whose name is a usable preset id as a preset slot: composition missing → broken ("still occupies the id; delete it or restore the file"), composition unreadable/unparsable/not-a-list-of-named-rows → broken with the parser's first line. The shape check parses with the loader's own `entryListSchema` (the `!!js` dialect), so health can never call broken what the loader would accept; directories whose names fail `PRESET_ID` are skipped outright, because no copy could ever collide with them. `broken` rides `AgentPreset`, the `agentPreset.list` wire entry, and the UI row. Mounting paths (`mount`/`recompose`/`standingKeyFor`) refuse a broken preset up front via `resolveMountable` with the discovery-reported reason; `resolve` still answers (delete/read/report need the row), and `copy`'s roster check now sees ghosts, which turns the "already exists" refusal actionable — the broken card to delete is on the same page.
|
||||
|
||||
Surfaces split by their job: the management section renders broken rows as marked cards (red border, Broken badge, verbatim reason, body and duplicate disabled, location/delete kept on custom rows — the files are the fix, delete is the ghost's way out; shipped broken rows lose the viewer too), while both pickers (General row, new-session chip) drop broken presets entirely via `presetOptions` — they choose the NEXT session's composition, and offering one that cannot compose only defers the failure.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The ghost dead end is gone end to end: the directory lists broken, its delete clears it, and the freed id is immediately claimable (covered by unit, component, and e2e tests).
|
||||
- A default that later breaks still fails the session start loudly — the pickers hide broken rows, but nothing rewrites a stored default; `resolveMountable`'s early refusal is the same message every unloadable shape gets, instead of loader-dependent errors.
|
||||
- Health runs on every `list()`: one read+parse per preset per roster read, accepted for the same reason unmemoized discovery was — rosters are small and freshness is the contract.
|
||||
- Copying broken is refused in the UI only (disabled with reason); the host keeps `copy` shape-agnostic. A broken source yields an equally broken, equally visible copy — no capability is gained, and the host-side refusal would have needed its own error vocabulary for no journey that survives the disabled button.
|
||||
|
||||
## Load-bearing details
|
||||
|
||||
- **`PRESET_ID` moved to `types.ts`** so discovery and authoring share one containment vocabulary; authoring re-exports it unchanged.
|
||||
- **The reason is one line.** js-yaml appends a multi-line code-frame snippet; the roster card is not a terminal, so `compositionProblem` keeps the first line.
|
||||
- **Two mount.spec races were left untouched deliberately**: `ensureStanding` is still reachable with a preset resolved just before deletion (the private-path tests), and its stamp/unstampable semantics are unchanged — the health check happens before, in the public route.
|
||||
- **Creator-mode guidance rides the same PR**: the `cordis` preset's persona now forbids editing the shipped install (corrupting `cordis` would disable the mode itself) and points authoring at `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/`; its skill teaches `preset.yml` metadata, the copy-first workflow, the one-escalation sandbox reality (the preset root lies outside the session workspace), and honest verification (the agent cannot start sessions; the settings page's red marking is the user's check). Verified live: asked to edit the shipped `cordis` composition directly, the composed agent refuses citing both rules and offers the copy path; asked for a real preset, it lands it under `$DSH_HOME`, batches writes into one escalation, self-checks with the loader dialect, and hands verification to the user.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Hiding broken presets but refusing the id at copy time with a better message: still no way to clear the ghost from any surface. Validating deep (resolving every row's module at list time): the mount already owns that failure with rollback, and per-row imports on every roster read would be neither cheap nor more actionable. Blocking `settings` writes naming a broken default: the settings domain is generic and the roster is a live directory — a name absent or broken now may be valid by the next session, and the mount's loud failure is the enforcement that owns the moment.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note:损坏的 preset 是名单行,不是空缺
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-09-broken-preset-roster-rows.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
文件成为唯一的组装编辑器之后,手动编辑造成的损坏有两种形态,且都要拖到最糟的时刻才暴露。`agent.cordis.yml` 解析不了的 preset 在名单上是一张完全正常的行——可选择、可复制、可设为默认——直到下一个会话尝试挂载才失败;一旦被设为默认,所有新会话都无法启动。组装文件被整个删掉的目录则从名单上消失,却仍在磁盘上占着它的 id:`copy` 以「先删除既有 preset」拒绝这个名字,`remove` 却回答「找不到」——两条互相矛盾的错误,除了手动删目录别无出路。
|
||||
|
||||
## 决定
|
||||
|
||||
发现过程负责健康,受损目录是**携带 `broken` 原因的名单行**,绝不是空缺。`scanRoot` 把名字是可用 preset id 的每个目录都当作一个 preset 槽位:组装缺失 → broken(「仍占着该 id;删除目录或恢复文件」),组装不可读/解析失败/不是具名行列表 → broken 并携带解析器的首行。形状检查用加载器自己的 `entryListSchema`(含 `!!js` 的方言)解析,因此健康检查绝不会把加载器接受的组装叫作损坏;名字不符合 `PRESET_ID` 的目录直接跳过,因为复制永远不可能与之相撞。`broken` 依次落在 `AgentPreset`、`agentPreset.list` 的线上条目和 UI 行上。挂载路径(`mount`/`recompose`/`standingKeyFor`)经 `resolveMountable` 用发现时记下的原因在前置拒绝;`resolve` 照样应答(删除/读取/上报都需要这一行),而 `copy` 的名单检查现在看得见幽灵,让「已存在」的拒绝变得可操作——要删的损坏卡片就在同一页上。
|
||||
|
||||
界面按职责分开:管理区把损坏行渲染为标记卡片(红边、「已损坏」徽记、原样展示原因、卡片主体与复制禁用,自定义行保留位置与删除——文件正是修复处,删除正是幽灵的出路;损坏的内置行连查看器也不给),而两个选择器(通用设置行、新会话 chip)经 `presetOptions` 完全不列损坏的 preset——它们选的是下一个会话的组装,端出无法组装的选项只会推迟失败。
|
||||
|
||||
## 后果
|
||||
|
||||
- 幽灵死路端到端消除:目录以损坏行列出,删除即清掉,释放的 id 立刻可用(单测、组件测试与 e2e 各自覆盖)。
|
||||
- 事后才损坏的默认值仍会在会话启动处大声失败——选择器隐藏损坏行,但没有任何东西改写已存的默认;`resolveMountable` 的前置拒绝让每种不可加载形态得到同一条消息,而不是依赖加载器内部的报错。
|
||||
- 健康检查随每次 `list()` 运行:每次读名单对每个 preset 一次读取加解析,接受的理由与不做缓存的发现相同——名单很小,新鲜是契约。
|
||||
- 复制损坏 preset 只在 UI 层拒绝(按钮禁用并给出原因);宿主的 `copy` 保持形状无关。损坏来源产出同样损坏、同样可见的副本——没有能力增益,而宿主侧拒绝需要为一条被禁用按钮挡住的路径专门发明错误词汇。
|
||||
|
||||
## 关键细节
|
||||
|
||||
- **`PRESET_ID` 移到 `types.ts`**,让发现与创作共享同一份包含边界词汇;authoring 原样转发导出。
|
||||
- **原因只留一行。** js-yaml 会附上多行代码框摘录;名单卡片不是终端,`compositionProblem` 只保留首行。
|
||||
- **mount.spec 的两个竞态用例特意不动**:`ensureStanding` 仍可能拿到删除前一刻解析出的 preset(私有路径测试),其 stamp/unstampable 语义不变——健康检查发生在此之前的公开路径上。
|
||||
- **创造模式的引导随同一 PR 落地**:`cordis` preset 的 persona 现在禁止编辑随附安装(损坏 `cordis` 会禁用这一模式本身),并把创作指向 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/`;其技能新教了 `preset.yml` 元信息、先复制再改的流程、一次升级的沙箱现实(preset 根目录在会话工作区之外)与诚实的验证方式(agent 无法自己启动会话;设置页的红色标记是用户的检查项)。已实测:被要求直接改随附 `cordis` 组装时,组装出的 agent 援引两条规则拒绝并给出复制路径;被要求真正创建 preset 时,它落在 `$DSH_HOME` 下、把写入合并为一次升级、用加载器方言自查、并把验证交还用户。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
隐藏损坏 preset 但在复制时用更好的报错拒绝该 id:幽灵仍然无法从任何界面清除。深度校验(读名单时解析每一行的模块):挂载已经拥有这一失败并带回滚,每次读名单逐行 import 既不便宜也不更可操作。阻止 `settings` 写入指向损坏默认值:settings 领域是通用的,而名单是活目录——此刻缺失或损坏的名字到下一个会话可能已经有效,挂载的响亮失败才是拥有那一刻的强制点。
|
||||
Reference in New Issue
Block a user