这个插件不报错,只是悄悄地不生效。我开发 @local/dsh-worktrees,经历了四轮迭代,踩了 21 个这样的坑。每一个都是通过实际运行取证才暴露出来的。
插件本身并不复杂:用 git worktree 管理仓库。起步时只有"工具 + 设置页",四轮迭代后,增加了侧栏图标和中央面板,接管左栏并支持用拖拽把会话绑定到工作树,还加上了持久快照缓存。开发环境是 DSH 0.2.2-rc.1 Desktop(profile desktop)、Node v22.12.0、git 2.50.1、macOS。
真正耗时间的不是功能开发,而是处理那些"看起来成功"的时刻:安装返回 application: applied,只说明插件行已激活,不代表路由已注册,更不代表页面加载了新模块。下面按问题类型分组,逐条说明症状、根因和处置,最后附上 21 条速查表。
DeepSeek Harness 插件:为什么打开面板就报 HTTP 405?
症状很明确:插件安装成功,工具也能正常调用,但一打开 UI 就显示「加载工作树失败:HTTP 405」。用 curl 复现时,POST /worktrees/api → 405,而 GET /worktrees/api → 404。
405 + 404 这个组合本身就是证据:非 GET/HEAD 请求一律返回 405,找不到文件则返回 404,这是静态资源兜底的行为特征。也就是说,请求根本没有到插件。第一反应以为"路由写错了",其实并非如此。
根因在激活时序:
const webServer = ctx.get('webServer') // 冷启动时还是 undefined
if (webServer) webServer.register(...) // 于是静默跳过,一行日志都没有
插件行在冷启动时可能早于 webServer 就绪而激活。这段代码只读取一次;如果当时拿不到服务,就会永久放弃。对于可选服务,应该"等服务出现",不要"读一次就走":
whenService(ctx, 'webServer', (webServer) => own('plugin api route', () =>
webServer.register({
kind: 'exact', path: API_BASE, handler })))
function whenService(ctx, name, use) {
const existing = ctx.get(name)
if (existing) return use(existing)
if (typeof ctx.inject !== 'function') return
ctx.inject([name], (inner) => {
const service = inner.get?.(name) ?? inner?.[name]
if (service) use(service)
})
}
斜杠命令(commands)也遇到了同样的问题,当时一并失效了。服务的注册与使用规则,可以对照插件与依赖注入了解。
还有一个连带问题:最初,三个注册都写在同一个 apply 里。webServer.register 一旦抛错,整行就会 did not activate,于是工具也无法使用。后来改为每个注册都单独用 own(name, () => register()) 包起来,并在内部 try/catch;注册失败时只 console.warn,再降级对应界面。
DeepSeek Harness 插件:热重载为什么抢不回路由?
set_bundle 关→开时,会报 webserver: duplicate exact route "/worktrees/api"。更隐蔽的是,忽略这个错误后,旧 handler 仍然在运行旧代码。验证方法很直接:调用一个只有新代码才认识的动作:
POST {"action":"tree"} → {"ok":false,"error":"unknown action \"tree\"; expected one of list, repos, …"}
旧的动作列表还在,说明跑的是上一代模块。
这一轮最有价值的设计是:每次激活都注册一个唯一路径 /worktrees/api/<12 位随机 hex>,再通过 webServer.tapIndex() 注入一段 <script>,告诉页面当前路径。固定路径只用于兼容旧页面。当前路径会写入 ~/.dsh/worktrees/runtime.json,出问题时可以先检查这个文件。
教训是:插件热重载不是"换代码",而是"再挂一代"。对于任何全局命名空间——路由、命令、工具名、槽位 id、main key——都要假设上一代仍然存在。这也解释了为什么插件更新后需要重启 profile有时反而更省事。
同一类问题还有工具重名:注册会直接失败。DSH 自带一个可选但未安装的实验 bundle @deepseek-ai/dsh-experimental-tool-worktree,它也会注册名为 worktree 的工具。注册前先用 ctx.tools.get('worktree') 检查;如果名称已被占用,就改名(worktree → worktree_manager),并在文档中说明冲突关系。
DeepSeek Harness 插件:工具返回值为什么要"无损 JSON"?
这是代价最高的一个坑,因为它会让成功看起来像失败:delete 实际已经删掉工作树,但模型收到 tool "worktree" returned invalid output: value is not lossless JSON,于是又重试删除,并误判了状态。
根因是返回体中包含 cwd: undefined(没有切换目录时就不设置该值)。经过 JSON 序列化再解析后,这个键会消失,因此无法通过"无损"校验。返回前递归剔除 undefined:
function pruneUndefined(value) {
if (Array.isArray(value)) return value.map(pruneUndefined)
if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
const out = {
}
for (const [k, v] of Object.entries(value)) {
if (v === undefined) continue; out[k] = pruneUndefined(v) }
return out
}
return value
}
这个问题值得单独记录,因为它改变了验证策略:无论实现看起来多干净,都要在工具定义层跑一遍无损 JSON 断言。直接调用已注册的 tool definition,对每个动作逐一断言,而不是重新阅读源码。Date、Error、BigInt 也一样。
这个 bug 还带出一个更深的教训:它是我遇到的两个真 bug 之一,而且当时自测脚本全绿。最后是独立验证的 agent 从模型侧调用时发现的。如果验证者和实现者使用同一套假设,就可能发现不了这类问题。
DeepSeek Harness 插件:单槽位为什么只能替换不能插入?
需求是"在左栏「工作区」同级新增「工作树」区域"。实测槽位拓扑后,我发现这个需求在槽位层面无法实现:
sidebar:single,被官方SidebarRoot占用,注册即替换整列;sidebar.workspaces:single,"工作区/会话浏览区",被官方 browser 占用;sidebar.panellist:list,每个 id 寻址一个主面板;main:keyed,"Central panel selected by sidebar entry id"。
所以只有两种选择:接管整个浏览区并替换官方列表,或者把工作树放进中央面板。最后我选了接管,因为要实现"把会话拖到工作树",就得由我们自己渲染会话行,而会话行只在浏览区里。
代价必须提前说清楚:接管后,官方 browser 的子座位(sidebar.session.row.leading/.trailing/.hover、sidebar.workspaces.directoryFlow)会随之消失,工作区重命名和删除弹窗也不再可用。这是产品决策,不是实现细节。
配套四个小坑:
sidebar.panellist只提供图标和元数据。点击时,它会用 id 选择main的 key,两处 id/key 必须严格同名,否则点击后没有反应。- 一个 slot entry 抛出异常,整个槽位就会白屏(
slot entry crashed in '<slot>')。接管左栏时,这会导致导航消失,是最坏的情况。所有数据路径都要加 try/catch,并提供 loading/empty/error 降级;还要提供"返回会话"按钮,通过ctx.layout.selectPanel(null)返回。 - 改完
client.js后,界面不会自动更新:浏览器会按 URL 缓存动态客户端模块,刷新(Cmd+R)后才生效。也可以不打开浏览器来判断:检查官方 browser 的子槽位是否还在。如果sidebar.session.row.leading仍是available,且declaredBy是官方条目,就说明接管没有生效。 - UI 的 HTTP 请求没有会话上下文。"当前工作树"需要会话 cwd,但请求是匿名的,所以
current要在浏览器端根据useSessions快照计算,服务端只返回路径。
DeepSeek Harness 插件:持久缓存会固化错误吗?
为了进一步压低冷启动耗时(当时是 830ms,更早时是 5.2s),我加了持久快照、先返回陈旧数据、后台刷新和激活预热。随之踩到的四个坑,都和并发及缓存有关:
| 坑 | 现象 | 处置 |
|---|---|---|
rm(snapshot) 是异步的 |
变更后立刻删快照,紧接着的读仍读到旧快照 | 同步 snapshotDirty,读路径先看它 |
| 后台刷新覆盖新状态 | 变更前开始、变更后完成的刷新把旧状态写回 | 代际号:刷新开始记 generation,落盘前对不上就丢弃 |
| 结构构建污染统计 | 之后读到的"变更文件数"全是 0 | stats 进缓存键;只有带统计的构建才允许写快照 |
| 会话 cwd 失效 | 删掉会话所在工作树后,空快照落盘,面板看起来"仓库全没了" | 把发现缓存里的已知仓库也作为候选来源 |
教训是:缓存一旦持久化,就不只是"加速",还可能"固化错误"。同步脏标记、代际号和写入白名单(只有完整数据才能落盘)这三项缺一不可。
实测数据(6 仓库 / 12 工作树):重启后的首次读取为 6–14ms(cached: true),稳态为 2–4ms,带统计的读取约 4ms,强制 refresh: true 需要 3.1s,变更后的首次结构读取为 0.94s。
DeepSeek Harness 插件:git worktree 有哪些语义陷阱?
node_modules/ 匹配不到符号链接
我用 symlink 把共享依赖挂进新工作树,结果它在每个新工作树里都显示为未跟踪。根因是 .gitignore 中写的是 node_modules/(带斜杠),它只匹配目录,不匹配 symlink。处理方法是:materialize 之后,把被链接的路径写进 <git-common-dir>/info/exclude。这样设置对所有工作树生效,也不会修改任何受版本控制的文件。
macOS 的 /var 与 /private/var
/var/folders/… 是 /private/var/… 的符号链接。我们用前者构造路径,git 却报告后者,结果很隐蔽:显示名和评审基线会悄悄丢失,删除时也找不到登记项。处理方法是:登记表的键和查找统一使用 realpath,并且在删除目录前先算好规范路径。目录删掉后,realpath 就会失败,只能退回原路径,仍然无法匹配。
脏工作树 ≠ 未合并分支
force:false 的语义很容易被误读:工作树有未提交改动时,拒绝删除;工作树干净但有未合并提交时,允许删除目录、保留分支(branchKept: true)。第一次外部验证时,对方把它理解成"拒绝删除失效";其实是文档没有把这两种情况分开说明。
保留分支导致同名重建失败
删除一个有未合并提交的工作树后,再创建同名任务,就会报 fatal: a branch named 'worktree/x' already exists。处理方法是:create 时如果分支已存在,就复用它(git worktree add <path> <branch>,不带 -b),并在返回值和文案中说明"复用了保留分支,从它的提交继续"。
用 basename 当仓库目录会撞车
目录按 ~/.dsh/worktrees/<repoName>/<slug> 组织时,遇到两个同名仓库(我机器上确实有同一个前端仓库的两个 clone)就会互相覆盖。于是我在目录名中加入仓库根目录的短哈希(demo-3f9a1c),再在 UI 中用父路径后缀区分(…· cyk 与 …· CtripProject)。
DeepSeek Harness 插件:测试环境容易出什么问题?
环境变量要在构造前设置。 有次测试忘了把 root 指向 tmpdir,结果测试工作树写进了真实数据目录 ~/.dsh/worktrees。处理方法是在构造前设置 DSH_WORKTREES_ROOT,并加一道护栏:
if (!process.env.DSH_WORKTREES_ROOT?.startsWith(tmpdir())) throw new Error('refusing to run')
node --test test/ 在 Node 22.12 上不可用,会报 MODULE_NOT_FOUND,因为位置参数会被当成模块路径。应使用 node --test(自动发现)或显式指定文件路径。
git 身份会导致测试偶发失败:首跑 43/45,重跑 45/45。处理方法是先用 git var GIT_AUTHOR_IDENT 探测;如果没有身份,首次运行时就带上 -c user.name=… -c user.email=…,并在测试中清除 GIT_AUTHOR_*/GIT_COMMITTER_*。还要避免反向问题:不要给生产代码注入空 GIT_CONFIG_GLOBAL,否则会丢掉用户的 credential helper、core.sshCommand、safe.directory,直接破坏 push 认证。隔离只在测试侧进行。
DeepSeek Harness 插件:安装失败和你的代码无关?
ERR_PNPM_FETCH_404 …/@agents-anywhere%2Fdsh-bridge-next
profile 里已有的某个依赖在公司 npm 镜像中不存在,pnpm 做全量解析时就会失败。处理方法是给 install_bundle 显式传入 registry: https://registry.npmjs.org。教训是:安装失败时,先确认问题出在"你的包",还是"profile 里别人家的包"。
另一个问题是 {"code":"already-installed"}。安装操作是幂等的,不负责替换代码;更新代码要靠 set_bundle 关→开(触发重新挂载)或重启 Harness。不过,路由这类全局注册还要留意前面提到的热重载问题。
最后,接管左栏这种改动必须留一个逃生门:进入设置 → 插件 → 禁用 Worktrees(左栏脚部不在被替换区域内)。禁用后,原生浏览区会恢复,工作树、分支和绑定仍留在磁盘上。
DeepSeek Harness 插件:21 条速查表
| # | 坑 | 症状 |
|---|---|---|
| 1 | 冷启动时服务还没就绪 | 工具能用,POST /api 一律 405、GET 404 |
| 2 | 热重载抢不回路由 | duplicate exact route,旧 handler 继续服务旧代码 |
| 3 | 单个资源注册失败整行不激活 | 1 entry did not activate |
| 4 | 工具返回值不是无损 JSON | returned invalid output: value is not lossless JSON |
| 5 | 工具重名冲突 | 同层注册同名工具直接失败 |
| 6 | 单槽位只能替换不能插入 | 左栏无法新增同级分区 |
| 7 | 图标 id 与主面板 key 不配套 | 图标点了没反应 |
| 8 | 客户端改完页面不会自己变 | 代码已改、界面还是旧的 |
| 9 | 符号链接不被 .gitignore 匹配 |
新工作树里 node_modules 显示未跟踪 |
| 10 | macOS /var vs /private/var |
显示名/base 丢失,删除找不到登记项 |
| 11 | 删除后 realpath 失效 | 登记项残留,git worktree list 与 UI 不一致 |
| 12 | 删掉会话所在工作树 | 会话 cwd 指向已删除目录,列表变空 |
| 13 | 保留分支导致重建失败 | a branch named 'worktree/x' already exists |
| 14 | 仓库同名目录互相覆盖 | 两个同名仓库只能有一个 |
| 15 | 后台刷新覆盖新状态 | 改名/绑定后偶尔读回旧数据 |
| 16 | 结构构建污染统计 | 之后读到的变更文件数全是 0 |
| 17 | 测试写进真实数据目录 | ~/.dsh/worktrees 冒出测试工作树 |
| 18 | 目录形式跑测试失败 | MODULE_NOT_FOUND(Node 22.12) |
| 19 | 依赖镜像缺包 | ERR_PNPM_FETCH_404 |
| 20 | 已安装无法重装 | {"code":"already-installed"} |
| 21 | 文档数字会过期 | README 写 118/118,实际 126/126 |
DeepSeek Harness 插件:写之前先问自己 10 个问题
- 我要实现的能力属于"服务"还是"槽位"?服务在
apply时可能还不存在吗? - 我要注册的全局名字(路由 / 命令 / 工具名 / 槽位 id / main key)会被官方或其他插件占用吗?
- 热重载后,我的注册能回收吗?如果不能,会怎样?
- 这个槽位是 single 还是 list/keyed?single 意味着"替换",我会失去哪些官方子能力?
- 组件崩溃后会导致什么内容白屏?有没有"退回原生"的路径?
- 工具返回值是无损 JSON 吗?其中有
undefined、Date、Error或BigInt吗? - 返回值中有哪些字段依赖"当前会话"?(HTTP 请求没有会话)
- 缓存会持久化吗?脏标记是同步的吗?有代际号吗?写入有白名单吗?
- 路径比较前做过 realpath 归一吗?
- 出问题时,用户怎么一键恢复?
从零开始写插件的骨架部分,可以对照如何从零开始开发 DeepSeek Harness 插件;注册的东西怎么在卸载时回收,见插件作用域与资源清理。
结语
如果只记三句话:
- "没报错"不等于"生效了"——服务时序、路由回收和页面模块缓存,这三处都能悄悄骗过你。
- 边界要做断言,不要靠重读代码——工具返回值是否为无损 JSON、槽位是否确实由我占用,都要通过可运行的取证来验证。
- 持久缓存是"加速 + 固化错误"的双刃剑——同步脏标记、代际号和写入白名单,一个都不能省。
相关阅读
- 如何从零开始开发 DeepSeek Harness 插件?
- DeepSeek Harness:Cordis 插件、服务与依赖注入
- DeepSeek Harness:插件作用域与资源清理有什么关系?
- DeepSeek Harness:插件更新后为什么需要重启 profile?
本文由 DSH Plugin 社区(dsh-plugin.com)维护者撰写,首发于阿里云开发者社区。
文中引用的插件开发文档收录于:https://dsh-plugin.com/zh/blog