# 开发规范与事故复盘 > 本项目在 DSH 0.1.0-rc.6 / rc.7 / 0.1.2-rc.1 / 0.1.7-rc.2 上真实踩过的六类事故,以及由此固化的开发检查清单与发布流程。 > 事故时间:2026-08-15 ~ 2026-08-18(前五类)、2026-09-25(第六类,0.1.7 适配)。 > 2026-09-28 补 0.2.0-rc.1 适配记录(§1.7,非事故:契约核对 + 真实启动验证)。代码级细节见 [架构与机制](架构与机制.md)。 ## 1. 事故复盘 ### 1.1 ESM 重导出不产生局部绑定 - **现象**:启动 `dsh web` 报 `plugin tree failed to load … SEARCH_POOL_PROVIDER_ID is not defined`。 - **根因**:`export { X } from './provider.js'` 只转发名字,**不会**在当前模块建立局部绑定,而入口内部直接使用了 `X`。 - **修复**:改为 `import { SearchPoolProvider, SEARCH_POOL_PROVIDER_ID } from './provider.js'` 后再 `export`。 - **教训**:入口内部要用的常量/函数必须显式 `import`;改完至少 `node --check` 并实际重启验证 `apply`。 ### 1.2 向会话日志写未注册事件导致历史加载失败 - **现象**:会话重启后 `SessionFormatUnsupportedError: … event type "web/search-pool-attempt" … not marked ignorable; refusing to interpret the log`,整个会话历史打不开。 - **根因**:为可观测性调用了 `session.append('web/search-pool-attempt', …)`,该事件不在核心 `KNOWN_SESSION_EVENT_TYPES` 中;读取端遇到未知且未标 `ignorable: true` 的事件会拒绝整份日志,且 rc.6 的 API 不开放 `ignorable`。 - **修复**:受影响日志补 `"ignorable": true` 恢复;插件改走 `ctx.logger?.info(...)`,不再写会话事件。 - **教训**:**禁止写核心不认识的会话事件**;观测优先 `ctx.logger`。 ### 1.3 额度总览不更新 / 「立即刷新」静默无反馈 - **现象**:设置页额度一直是默认值;点「立即刷新」后 `usageRefreshTick` 递增但 `usage.updatedAt` 不变,也没有任何提示。 - **根因(三层)**: 1. Host 用 `ctx.get('settings')` 取服务,插件上下文里可能拿不到 → 发布函数静默 return;Client 用带 `expectedRevision` 的写入 → 撞 `SettingsConflictError`,tick 写不进去。 2. 真正根因:`installSettingsSection` 的 `scope.watch` 在 `include:` 装配下**已注册但不触发**,插件 `onChange` 不执行。 3. `writeUsage` 分两次写 `usage` / `usageDiagnostic`,失败路径连诊断都丢;Client 用空 `.catch` 吞掉 mutate 拒绝。 - **修复**:Host 改 `ctx.inject(['settings'])` 取服务、未就绪存 `pendingUsage` 补发;入口同时监听 `settings/updated` 事件用 `next` 快照触发刷新;`refreshUsage` 加 15s 总超时并聚合每 key 失败进 `usageDiagnostic`;`writeUsage(snapshot, diagnostic)` 单次 `settings.update`;Client 不再吞错并显示「已请求刷新 / 刷新请求失败」。 - **教训**:Host 取共享服务用 `ctx.inject` 而非 `ctx.get`;settings 驱动的动作要有事件兜底;用户可点的「刷新」必须有可见结果;网络刷新必须带超时;诊断与业务数据尽量同一次写入。 ### 1.4 rc.7 keyed slot 用旧版 id 注册 - **现象**:升级 rc.7 后设置卡片消失,控制台 `settings.plugin.item slot requires options.key`。 - **根因**:rc.7 把 `settings.plugin.item` 从 list slot 改为 **keyed slot**,必须用 `key`,且 key 要与 Host 的 settings namespace 严格一致(本项目 `web-search-pool`)。 - **修复**:`ctx.slots.register({ name: 'settings.plugin.item', key: 'web-search-pool', order: 21 }, Card)`。 - **教训**:升级宿主前先核对 slot 契约的破坏性变更;为此建立了 bundle / client / install 三类契约测试防回归。 ### 1.5 rc.7 仍依赖 `WEB_SETTINGS_NAMESPACES` 白名单脚本 - **现象**:rc.7 下仍跑 `scripts/patch-api-proxy-namespace.mjs`,找不到目标数组并影响启动。 - **根因**:rc.7 的 `dsh-host-apiproxy` 不再用该白名单,已注册的 settings namespace 由设置代理直接暴露;该脚本是 rc.6 时代的安装目录 patch 手段。 - **修复**:脚本降级为诊断模式(检测不到白名单即提示并成功退出);安装链路改由 Bundle patch 原生接管。 - **教训**:避免依赖 DSH 安装目录 patch,优先官方 Bundle 机制;所有机制结论标注调研日期与源码来源。 ### 1.6 0.1.7 桌面版:client half 等服务等到 web boot 崩溃 - **现象**:0.2.1 装进桌面版(0.1.7-rc.2)后,设置页没有「搜索 Key 池」卡片;重启应用报 `web boot: 1 entry did not activate` / `dsh-web-search-pool: pending (waiting for service: settingsScope)` (崩溃日志实证于桌面版用户数据目录下 `logs/crash-*-web-boot.log`, 即 `@deepseek-ai/dsh-desktop/logs/`)。 - **根因**:0.1.7 重写了设置体系——`ctx.settingsScope` 服务、`settings.plugin.item` slot、 `settings.installSection` 全部移除,设置表单改由 loader 行 Config schema 自动投影 (volatile 字段)、client 数据面改走 `ctx.configForms`、行配置走 `plugins.row.config` slot。 client half 的 `exports.inject` 还写着 `settingsScope`,cordis 永远等不到这个服务, entry 卡在 pending,而浏览器端启动审计对失败 entry **fail loud**,整个 web surface 起不来。 - **修复**(0.3.0):client half 按官方 companion 包(`dsh-client-ui-settings-web-search`) 同构重写——`configForms.get(ns)` + `whileServed` 门控 + `plugins.row.config`(key = `dsh-web-search-pool#web-search-pool`)+ `form.mutate(ops, revision)` 写事务; host half 删除 installSection 路径,Config 全字段 `.volatile()`,监听 `loader/volatile-update` 同步 web 行;运行时 usage 字段移出 schema(改 `ctx.logger`)。 - **教训**: 1. **client 的 `exports.inject` 是生死线**——声明一个宿主不提供的服务,浏览器 entry 永远 pending,web boot 直接失败。改 inject 前必须在目标宿主源码里确认服务存在。 2. 宿主升级先查三张表:service 提供方、slot 清单(`plugins.*` / `settings.*`)、事件名。 0.1.7 的 slot/服务清单可从 `cordis-client-runner` 的 slot contract 与各包 `inject` 反推。 3. 崩溃日志优先看 `logs/crash-*-web-boot.log`(Electron 侧)与 `$DSH_HOME/logs/startup-*.log`(宿主侧), symptom 定位到具体 entry。 4. 「设置页没卡片」和「重启报错」往往是同一个根因(entry pending),不要当两个问题查。 ### 1.7 0.2.0-rc.1 适配记录(2026-09-28,无事故) - **背景**:桌面版 nightly 升到 0.2.0-rc.1 后,0.3.0 的 peer 范围 `^0.1.7-rc.1` 被 启动兼容门拒绝:`evaluatePluginCompatibility` 对每个 `@deepseek-ai/dsh*` peer 求 `semver.satisfies(运行时, 区间, {includePrerelease: true})`,不满足即跳过 bundle 层 (`skippedBundles` 非空,插件静默不加载)。 - **核对方法**:`resources/app.asar` 用一个小 Python 脚本读头部 JSON 后按包提取 (asar 布局:`uint32 pickle 大小 + pickle + uint32 头长 + 头 JSON`,文件数据从 `8 + 头 pickle 长度` 起、offset 相对该基点;标记 unpacked 的文件在 `app.asar.unpacked` 旁路)。对照插件源码的 API 清单逐项 grep 0.2.0-rc.1 源码。 - **三张表核对结果**(0.1.7 → 0.2.0): - **service**:`ctx.web.registerSearchProvider`(dsh-web `WebRuntime`,provider 面 `id`/`available()`/`search(request, signal)` 不变);`credentials.resolve` (dsh-credentials);`configForms`(dsh-client-ui-settings:`get`/`whileServed`/ `mutate(ops, revision)`、快照 `{status, value, revision}` 不变)。 - **slot**:`plugins.row.config` keyed slot 仍在(`dsh-client-ui-plugin-manager` 的 `rowConfigKey(bundle, rowId)` = `<包名>#<行 id>`)。 - **事件**:`loader/volatile-update` 仍在——0.2.0 把 loader 拆成独立包 `@deepseek-ai/cordis-plugin-loader`(cordis 4.0.4 的 peer),volatile-only 变更 就地提交 Refs 后向 owning fiber 发射该事件;`credentials/reference-updated` 在 api-remotes 转发白名单内。client 侧另有 `settings/document-updated` (dsh-settings 服务发给 owner context,浏览器经 remote 转发),host half 无需监听。 - 其余:`dsh-base` 的 `web` 行仍在根组(patch 写短 id `web` + restate `fetchProvider: http` 的整体替换语义不变);`deepseek-official` provider id 不变。 - **结论**:双线并存,peer 范围改 `^0.1.7-rc.1 || ^0.2.0-rc.1`;`src/` 无功能改动。 - **验证**:`tests/dsh-version-compat.test.js` 的 RUNTIMES 增加 `0.2.0-rc.1`、边界断言 翻正(0.2.0 final 入列、0.3.0 拒绝);`npm run test:local` 122 例全绿(peer 包同步为 0.2.0-rc.1 副本);`scripts/e2e-boot.mjs ` 在真实 0.2.0-rc.1 运行时 全绿(skippedBundles 为空 / patch 生效 / 行 ACTIVE / settings 投影 / update 双向同步 / client 图谱下发)。 - **教训**: 1. peer 范围是**双线 `||` 联合**,不是一路抬地板;DSH nightly 的 minor 跳变可能伴随包 拆分(loader → `cordis-plugin-loader`),但事件/slot 契约先 grep 再下结论。 2. 兼容门只看 `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` peer,`cordis` / `schemastery` 等非 dsh peer 不进门禁但仍是真实依赖,升级时一并核对(0.2.0:cordis 4.0.4、 schemastery 3.18.4,均在原范围内)。 3. `workspace:^` / `workspace:~` / `workspace:*` 在兼容门里指代当前运行时版本;本插件 选择显式双线范围以保留真实下限声明。 ### 1.8 0.2.1-rc.2:「启用 key 池」仍走 DeepSeek 官方 API key(2026-10-19) - **现象**:设置卡片里「启用搜索 Key 池」为开,Tavily 4 个 key 已配置,但会话里的 `web_search` 仍走 `deepseek-search-api`(用户 profile 的 `web` 行 `searchProvider: deepseek-search-api`),日志无 `search-pool attempt`。 - **根因**:0.4.0 只在 `apply` 时把 `web` 行的 `searchProvider` 改成 `search-pool`。 但 `WebRuntime.search` 是**调用时**才按该字段选 provider,而用户 profile 的 `cordis.patch.yml` 在所有 bundle 层之后应用、官方 Web search 设置页也写同一行; 覆盖后请求仍走官方 provider(`deepseek-search-api` / `deepseek-official`), 消耗 `DEEPSEEK_API_KEY`。 - **修复**(0.5.0):接管点改为 **`ctx.web.search` 服务方法**——在 web 实例上安装 可逆 wrapper,启用态直接调用池 provider;不依赖 provider id、注册顺序、 `web.searchProvider` 或行 id。`cordis.patch.yml` 不再改写官方 `web` 行。 结果投影成 `{ content?, sources, truncated }`,默认不回退官方,错误中性化, 模型侧仍是同一个 `web_search` 工具、同一套系统提示词。 - **验证**: - `tests/dsh-host-apply.test.js`:服务级接管、未知 provider id、关闭委托、 maxResults 截断、中性错误、`fallbackToOfficial`、不可 patch 降级、dispose 恢复; - `scripts/e2e-boot.mjs` 在真实 0.2.1-rc.2 宿主(app.asar 提取)全绿: `web` 行保持 `deepseek-official`、`ctx.web.search` 有 own wrapper、 无 key 时真实宿主调用抛中性 `WEB_PROVIDER_UNAVAILABLE`、settings.update 不改写 web 行; - 合计 131 用例全绿。 - **教训**: 1. **volatile 配置是「调用时读取」的**——在插件 `apply` 里写一次配置不等于接管; 凡是用户层/patch 层会写的字段,都不能当作稳定注入点。 2. **拦截服务方法比改 provider 选择更稳**:`web_search` 只调用 `ctx.web.search`, 在这个入口上做可逆 wrapper 可以覆盖所有 provider id 与未来新增 provider。 3. **cordis Loader 不把 `apply` 返回值当 disposer**(cordis 4.0.4 实测):清理必须 注册在 `ctx.effect`;否则插件卸载后 wrapper 残留。 4. **模型无感是接口契约**:结果只保留官方 seam 同形字段,错误文案不出现供应商/端点; 实现细节只进 `ctx.logger` 与 `error.cause`。 ### 1.9 单次搜索内重复重试同一个失败 key(2026-10-21,GitHub issue #1) - **现象**(外部测评,0.4.0 @ `548fb1e`;0.5.0 同样存在):第一个被选中的 key 若返回 HTTP 5xx / 401 / 网络错或单次超时(**不是 429**),同一次搜索的下一轮又会选中它; `maxAttempts = keyPool.totalKeys` 很快用尽,同供应商的健康 key 和下一个供应商都不会 被尝试。最常见「1 个 Tavily key + Exa 兜底」在 5xx/超时时不切换;上游挂起时每次搜索 耗满宿主预算(默认 30s)后失败。 - **复现**(本机,mock fetch + 真实 provider/scheduler 源码): `t1(rpm 60, 500) + t2(rpm 10, ok)` → upstream `["bad","bad"]` → `WEB_PROVIDER_ERROR`; `tavily 500 + exa ok` → 只试 tavily 两次;`tavily 挂起 + exa ok` → 同样失败。 直接驱动 `Scheduler`:rpm 60/10 下连续 3 次 `acquire()` 都返回 t1。 - **根因**: 1. `src/dsh/provider.js` 的 `_search` 每轮重新 `scheduler.acquire()`,不排除本次搜索 已试过的 entry; 2. `src/core/scheduler.js` 的 `acquire()` 没有排除参数,smooth WRR 按 `max(1, rpm)` 加权,高权重 key 连续胜出;同供应商只有 1 个 key 时它就是唯一候选; 3. `src/core/key-pool.js` 的非 429 失败只累加 `failCount`,要连续 `allowedFails` (默认 3)次才冷却,所以下一轮仍是候选。 - **修复**(0.5.1):`Scheduler.acquire(exclude?)` 增加调用级排除参数; `_search` 维护 `tried` 集合,选中 entry 后**先登记再走凭据/闸门**,后续 acquire 跳过。 一次搜索内每个 entry 至多尝试一次,失败立即换 key / 换供应商。冷却、失败计数、 权重轮转、错误映射与宿主可见契约(`ctx.web.search` wrapper、中性错误)均不变。 - **验证**: - `tests/scheduler.test.js`:`acquire(exclude)` 跳过已试 entry、排除全部后返回 null 且不影响后续调用; - `tests/provider.test.js`:同供应商 5xx 换 key、跨供应商 failover、全部失败时每 key 只试一次;改写原超时用例(rpm 60/10 + 上游序列断言); - 反证:临时移除修复后上述 4 个用例必红(132/136),恢复后 136 全绿; - 内联复现 A/B/C 全部成功(A `[bad, good]`、B `[tavily, exa]`、C `[hang, exa]`)。 - **教训**: 1. **重试循环必须可推进**:只靠「失败计数达到阈值才冷却」推进,在单次调用内可能永远 不推进;循环里要显式记录已试集合。 2. **测试数据要让调度真的偏置**:两个 key rpm 相同会让 WRR 恰好交替,把「同一 key 重复重试」测成假绿;回归用例必须构造权重差(60/10)。 3. 报告里「上游序列」是最直接的可观测断言:断言 key 序列比断言最终错误码更能守住 failover 语义。 ## 2. DSH 插件开发检查清单 1. **ESM 绑定**:入口内部使用的符号必须 `import`;`export { x } from …` 不产生局部绑定。 2. **会话事件**:禁止 `session.append` 写未注册且未标 `ignorable` 的自定义事件;观测用 `ctx.logger`。 3. **机制先查源码**:涉及 seam / settings / api-proxy / session / slot 的结论,先读目标宿主源码确认,不凭印象;桌面版从 `resources/app.asar` 提取核对。 4. **按影响验证**:只改文档查事实和链接;改逻辑运行相关测试;改入口、Host/client 或宿主契约时增加语法、契约及必要的真实启动检查。发布再做完整验收。重启、安装与联网验证须在授权范围内,不由小改动自动触发。 5. **不直接改 npm 安装目录**:必须 patch 时做成可重复脚本(如 `scripts/patch-api-proxy-namespace.mjs`),升级后重跑。 6. **凭据**:只走 `ctx.credentials`(`credentialRef` 为环境变量名),每次操作 resolve,不缓存明文、不写配置文件。 7. **客户端密钥写入顺序**:先 `await credentials.set` 成功再提交 settings mutate,保存后重新 `credentials.describe` 刷新徽标。 8. **运行时状态不进 settings**(0.1.7 起):settings 写入 = profile patch 落盘;Host 周期发布的运行时会话数据只能走 `ctx.logger`;配置字段全部 `.volatile()`。 9. **保存类 UI**:必须有失败反馈(冲突/拒绝)与恢复路径,不能只写日志;ops 只写变化字段,保持 patch 最小。 10. **参数 `off` 要覆盖同维度所有派生参数**(如 `timeRange: off` 必须同时禁用 `days`),测试覆盖。 11. **volatile 变化监听**:`loader/volatile-update`(0.1.7 取代 `settings/updated`)+ 值与上次比对防重,不单靠 scope 订阅。 12. **契约测试**:bundle / client / install / 版本兼容各有测试守住格式,升级宿主先跑 `npm run test:local`;client 契约测试会用 vm 真实物化 factory,inject/slot/服务名写错立刻红。 13. **清理走 `ctx.effect`**:cordis 的 Loader 不把 `apply` 返回值当 disposer;卸载清理、定时器、监听恢复必须注册在 effect 里(fiber 卸载途中 `ctx.off` 可能抛 `INACTIVE_EFFECT`,不要让它挡住恢复逻辑)。 14. **不要用「apply 时写一次」接管调用时读取的配置**:`web.searchProvider` 会被 profile patch / 官方设置页覆盖;需要接管时优先在服务方法上做可逆 wrapper,并覆盖 provider 晚注册/服务重挂。 ## 3. 发布流程 1. 改 `package.json` 的 `version`,并在 `CHANGELOG.md` 顶部加条目(保持「新增 / 变更 / 修复」三段)。 2. 全量测试与语法检查:`node scripts/run-tests.mjs`、`node --check` 关键文件。 3. 打包校验:`npm pack --dry-run` 确认包内含 `src/`、`scripts/`、`docs/`、`locale/`、`cordis.patch.yml`、`README.md`、`LICENSE`,不含测试与凭据。 4. 发布前安全检查:`node scripts/privacy-scan.mjs` 全历史扫描无个人路径/用户名/真实 key(用户名默认取当前系统账户,可用 `PRIVACY_USERNAME` 覆盖;检测器自身历史版本的模式表不计为泄漏);无硬编码密钥、凭据文件不在跟踪列表、文档无个人绝对路径、`CHANGELOG` 无敏感信息。 5. 仅在已授权发布时提交并推送;需要发版再打 tag,并在 GitHub Releases 附 tgz。 6. 本机安装验证:`node scripts/e2e-boot.mjs ` 全绿 + 桌面版插件管理装 tgz + 重启后插件页有卡片。 ## 4. 文档规范 - **一份信息一个出处**:对外门面在 `README.md`,安装/升级/排障在 `docs/安装与升级.md`,机制与设计在 `docs/架构与机制.md`,开发规范在本文;同一事实不写第二遍,只放链接。 - **机制结论标注来源**:写明调研日期 + 本地源码路径或官方链接(见 `docs/架构与机制.md` 开头的「事实边界」)。 - **路径占位符**:文档示例统一用 `$DSH_HOME` 与 `<...>`,不出现个人盘符与用户名。 - **过程稿不长期保留**:方案/计划定稿后并入 `docs/`,旧稿由 git 历史保留。