DeepSeek Harness 本地部署与第三方插件排障实录:从 fetch failed 到 preset not found
DeepSeek Harness 本地部署与第三方插件排障实录:从 fetch failed 到 preset not found
本文是一次完整真实的排障复盘:在 Windows 上从零跑通 DeepSeek Harness(dsh), 再装上一个第三方 client UI 插件,然后把踩到的每一个坑连根因带修法全部拆开。 文中所有报错原文、版本号、字节数、耗时、进程树结论均来自真机实测,没有推测。 配套交付:一份排障手册 + 5 个只读排障脚本(见第八节)。
📌 本文持续更新。最近一次追加的是故障 C(第六节):第二天启动时
DataDirectoryLocked把服务挡在门外,而它点名的那个"占用进程"跟 OpenViking 毫无关系。
〇、先给结论:四句话
如果你正准备装 dsh 或它的第三方插件,这四条能省掉大部分试错:
-
插件报的错,多半不是你的配置错。 我遇到的两个跟插件有关的故障——知识库页
fetch failed、新建会话preset not found——根因都在插件的发布包: 一个把硬依赖写成了"可选",另一个干脆漏打了一个目录。你的配置从头到尾没写错。 -
502是插件的兜底错误码,不代表 dsh 挂了。 看源码就知道,整个路由 handler 的catch分支统一return json(res, 502, ...),所有异常都是 502。 而且它的接口分三类行为,第三类会静默返回空——这是最容易误判的一条。 -
最隐蔽的坑不在安装,在"谁启动了服务"。 让 AI 助手在会话里帮你把服务跑起来, 服务会挂在会话的进程沙箱上:助手说"已就绪、路由全 200"是真的,过一会儿连不上也是真的。 想清理助手留下的后台任务又怕服务挂?根因在这里,修法在第七节。
-
报错信息里的"事实"要自己验一遍。
DataDirectoryLocked言之凿凿地说"另一个 OpenViking 进程正占用数据目录",但那个 PID 其实是迅雷,端口还是空的—— 报错是在陈述一个错误的前提。尤其是当它请你做一个有风险的动作时(比如"删掉这个锁"), 先把里面的具体值核一遍。详见第六节。
一、环境与版本(可对照)
先把坐标系定死,本文所有结论都基于这套环境:
| 组件 | 实测版本 | 说明 |
|---|---|---|
| 操作系统 | Windows 11 | 中文系统,代码页 936 |
| DeepSeek Harness | 0.1.5-rc.1(latest 标签) | 开发者预览版,官方明说会有破坏性变更 |
| dsh web UI | 127.0.0.1:3080 | 必须带 ?token= 完整 URL 访问 |
| OpenViking | 0.4.20 | 知识库服务,127.0.0.1:1933 |
llama-cpp-python | 0.2.90(cp313 / win_amd64) | 需官方 wheel 索引,PyPI 无 Windows 轮子 |
| 本地嵌入模型 | bge-small-zh-v1.5-f16(GGUF,47,886,240 字节) | 走 hf-mirror.com 下载 |
| 第三方插件 | dsh-client-ui-urban-planning-agent-workbench@1.1.0 | 城策工作台,城市规划业务 Agent 工作台 |
dsh 的版本标签:
latest= 0.1.5-rc.1,next= 0.1.5-rc.2,alpha= 0.1.5-alpha.2。 跟着官方 quickstart 用latest即可。
dsh 的核心公式是 Model + Harness = Agent,理念是"一切皆插件",底层是 Cordis 微内核。 理解这一点很关键——后面所有的坑,本质都是"插件组合"这一层的坑。
二、装 dsh:三个拦路虎
2.1 npm 默认源打不通(E502)
企业内网环境常见的坑:npm 默认源指向内网 Nexus,装 dsh 会直接返回 E502。 安装时必须显式指定官方源:
"<node>" "<npm-cli.js>" install @deepseek-ai/dsh@latest \
--prefix "<你的安装目录>" \
--registry=https://registry.npmjs.org/ --no-audit --no-fund
实测约 555 个包 / 2 分钟。
2.2 不要用 npm install -g
全局安装会污染环境,而且 dsh 的插件生命周期脚本要往用户目录写文件,全局装的权限和路径都容易出岔子。 统一装到固定目录,再写一个 .cmd 启动器暴露入口,这是更干净的形态。
2.3 别指望系统 Node 兜底
这一条值得单独说:实测用系统 Node 跑同一个 dsh 入口,返回空输出、退出码 0—— 不是报错,是静默失败,排查起来非常费劲。
所以启动器里必须写托管 Node 的绝对路径,并且不要清理那个运行时目录, 它是 dsh 能跑起来的前提。若该运行时被换版本,启动器里的路径要跟着改。
2.4 端到端验证:别只看"服务起来了"
Web UI 起来不等于模型链路通。最可靠的一条验证是真跑一次请求:
cd "<工作区>" && dsh --profile headless "只回复两个字:通了。不要调用任何工具。"
返回模型输出,说明 凭据 → 路由 → API → 回包 全链路正常。
一个容易吓到自己的细节:裸访问 dsh 的根路径返回
401 authentication required, 这是正常的——必须用启动日志里打印的那一行带?token=...的完整 URL。 而且 token 每次启动都重新生成(实测连开三次得到三个不同值),所以旧链接必然失效, 别收藏它。
三、装第三方插件:官方 INSTALL.md 的三处误导
第三方插件的 INSTALL.md 通常是按 macOS + 全局安装写的,照抄会卡住。
3.1 ✗ npm install -g —— 别用全局
插件自带的安装说明写的就是全局安装。两个问题:污染环境;生命周期脚本要往 ~/.agents/skills 写文件,全局装之后权限和路径都容易出岔子。
正确做法:装进 profile 的 node_modules,再在 patch 层注册一行。 profile 用的是 nodeLinker: hoisted(扁平结构),所以 npm 直接装是兼容的:
cd "<DSH_HOME>/profiles/web"
npm install "<插件包.tgz 的绝对路径>" --registry=https://registry.npmjs.org/ --no-audit --no-fund
官方路径 dsh plugin --profile web add <包> 转发给 pnpm——没装 pnpm 会直接报 'pnpm' 不是内部或外部命令。不想为此引入 pnpm 就用上面的 npm 直装。
3.2 ✗ 装完包装了 ≠ 加载了 —— 必须注册
这是最容易"以为装好了其实没生效"的一步。在 <DSH_HOME>/profiles/web/cordis.patch.yml 里加一行,否则包躺在 node_modules 里也不会被加载:
- insert:
- id: chengce-workbench # 自定 id,随便起
name: 'dsh-client-ui-urban-planning-agent-workbench'
然后重启 dsh web(新插件注册不是热重载)。
3.3 ✗ launchctl kickstart 是 macOS 的
INSTALL.md 给的重启命令是 macOS 的 launchctl。Windows / Linux 上直接重启 dsh 进程即可。
另外它没提:tgz 安装会在 profiles/web/package.json 留下脆弱引用,形如 "xxx": "file:../../../Downloads/xxx.tgz"——下载目录一清理就断链。 建议把 tgz 挪到固定位置,把依赖改成稳定相对路径,再 npm install 重解析一次。
3.4 ✓ 三层验证,缺一层都可能误判成功
| 层 | 怎么验 | 通过的样子 |
|---|---|---|
| 配置层 | dsh --profile web --dump-config | 配置树里出现注册的 id,stderr 干净 |
| node half | 请求任意插件路由,如 GET /chengce-knowledge/v1/skills | 返回插件自己的 JSON,而不是 404 |
| browser half | 抓首页 HTML,搜 <包名>/client.js | 出现在 boot 模块清单里 |
首页那串 boot 清单是最权威的浏览器端加载证据——它和内置插件并排列在 /plugins/... 序列中。
3.5 ✓ 确认内置 skill 装上了(8 个)
这类插件通常用 postinstall 把内置 skill 装到 ~/.agents/skills。本次实测装上了 u-policy + o-s1 系列 7 个,共 8 个。被跳过时手动跑(脚本带 marker 文件保护, 不会覆盖你自己创建的同名 skill,可重复执行):
node "<DSH_HOME>/profiles/web/node_modules/<包名>/scripts/install-bundled-skills.mjs"
顺带一个安装前的动作:本地 tgz 一律先审计再装。最小路径是
tarfile列清单 → 读package.json的scripts(找postinstall这类自动执行钩子) → 读读写文件系统的那几个模块 → 扫lib/client.js的eval/new Function/child_process/ 外部 URL。"0 个外部 URL"是最有价值的单一信号。 本次这个插件 10MB 前端产物里 0 个外部 URL、无eval/child_process,是干净的。
四、故障 A:知识库 / 技能管理页报 fetch failed(502)
4.1 先分清故障面:502 不等于 dsh 挂了
浏览器控制台看到 /chengce-knowledge/v1/* 返回 502 (Bad Gateway)。看源码 src/host.js,整个路由 handler 的 catch 是:
} catch (error) {
return json(res, 502, { ok: false, error: error instanceof Error ? error.message : String(error) });
}
所有异常统一返回 502。 所以看到 502,只说明"插件转发给 OpenViking 这一步失败了"。
而插件的接口其实分三类,行为完全不同——第三类是最容易误判的:
| 接口 | 源码行为 | OpenViking 挂掉时 |
|---|---|---|
/skills、/expert-bindings、/skill-configs | 只读写本地文件 | 仍 200 |
/health、/libraries | 直接 await ov(...),无 catch | 502 {"ok":false,"error":"fetch failed"} |
/resources | 每个 ov() 挂了 .catch(() => []) | 仍 200,但内容静默为空 |
第三类的含义:"知识库页打得开、但里面永远是空的",同样可能是 OpenViking 没跑——
resourceRows()把错误静默吞掉了。不要直接当成"我还没上传文档"。
一条命令看清三类(脚本见第八节),这是故障态下的实测输出:
3. 插件路由 —— 直接转发 OpenViking(OV 没跑就必然 502)
FAIL GET /health HTTP 502 {"ok":false,"error":"fetch failed"}
FAIL GET /libraries HTTP 502 {"ok":false,"error":"fetch failed"}
3b. 插件路由 —— 静默失败型(源码带 .catch,恒 200,要看内容)
OK GET /resources HTTP 200 {"ok":true,"resources":[]} ← 静默为空
4. 插件路由 —— 只读写本地文件(OV 在不在都该 200)
OK GET /skills HTTP 200
OK GET /expert-bindings HTTP 200
OK GET /skill-configs HTTP 200
结论
▸ OpenViking 没跑,而插件依赖它 —— 这就是「fetch failed / 502」的原因。
若 /skills 也失败(404 而不是 200),那说明插件根本没挂上——回到 3.2 节检查注册与重启。
4.2 为什么"技能管理"页也一起挂?
因为它和前端的加载逻辑有关。SkillsView.jsx 挂载时是:
const [libraryValue, bindingValue, configValue] = await Promise.all([
knowledgeApi.libraries(), // ← 唯一碰 OpenViking 的,挂了整页报错
knowledgeApi.bootstrapExpertBindings(DEFAULT_BINDINGS),
knowledgeApi.skillConfigs(),
]);
/skills /expert-bindings /skill-configs 单独请求都是 200,但 /libraries 一走 OpenViking 失败,Promise.all 就让整页进 catch。
所以插件作者的 INSTALL.md 把 OpenViking 写成"如需使用知识库功能,另行部署", 听起来是可选,实际是硬依赖。
而且前端是打包进 lib/client.js 的,没有构建链改不了——不能靠"给前端加容错"绕过, 唯一可行解就是把 OpenViking 跑起来。
一个很有用的细节:
lib/index.js只有一行export { apply, inject } from '../src/host.js'—— 也就是说服务端代码就是src/host.js源码本身,可直接改;只有前端是打包的。 这个区分对"能不能自己打补丁"至关重要。
4.3 装 OpenViking
OpenViking(volcengine/OpenViking,AGPL-3.0,PyPI 包名 openviking,要求 Python ≥3.10) 是知识库的存储 + 检索引擎。装进独立 venv:
"<python.exe>" -m venv "<venv 路径>"
"<venv>/Scripts/python.exe" -m pip install openviking # 实测 0.4.20,约 200+ 包
入口是 <venv>/Scripts/openviking-server.exe。
⚠️ 0.4.20 的
openviking-server没有init/doctor子命令——官网文档写的是别的版本。 直接带参数起服务即可:openviking-server.exe --config <ov.conf>。
4.4 两个"国内必踩"的依赖(最容易卡死的地方)
模型段在配置里全部可省,但服务端启动时会无条件创建 embedder,默认用本地 GGUF bge-small-zh-v1.5-f16。缺任何一样,服务会在 lifespan 阶段直接退出:
EmbeddingConfigurationError: Failed to download local embedding model
'bge-small-zh-v1.5-f16' from https://huggingface.co/... ConnectTimeoutError
① 模型文件:huggingface.co 国内不通。用 hf-mirror.com 手动下到缓存路径 (代码里会先查 <用户目录>/.cache/openviking/models/<filename>,存在就跳过下载):
curl -L -o "<用户目录>/.cache/openviking/models/bge-small-zh-v1.5-f16.gguf" \
"https://hf-mirror.com/CompendiumLabs/bge-small-zh-v1.5-gguf/resolve/main/bge-small-zh-v1.5-f16.gguf"
# 校验:应 47,886,240 字节,文件头 4 字节为 'GGUF'
② llama-cpp-python(本地嵌入的运行时,不在 openviking 的基础依赖里): PyPI 上没有 Windows 预编译轮子,pip install "openviking[local-embed]" 会走源码编译 (需 CMake + MSVC,基本必失败)。用官方 wheel 索引拿预编译版:
# 索引页(含 cp313 / win_amd64,可选 0.2.86 ~ 0.2.90):
# https://abetlen.github.io/llama-cpp-python/whl/cpu/llama-cpp-python/
"<venv>/Scripts/python.exe" -m pip install \
"https://github.com/abetlen/llama-cpp-python/releases/download/v0.2.90/llama_cpp_python-0.2.90-cp313-cp313-win_amd64.whl"
# 仅 3MB,秒装,无需编译
绕开这套的替代方案:改配 API 型 embedding(
openai/volcengine/dashscope/jina/glm/ollama/litellm…)。注意本地跑 Ollama 要另装,默认端口 11434。
4.5 配置 ov.conf:auth_mode 必须是 dev
{
"server": { "host": "127.0.0.1", "port": 1933, "auth_mode": "dev" },
"storage": {
"workspace": "<你的数据目录>/openviking/data",
"agfs": { "backend": "local" },
"vectordb": { "backend": "local" }
}
}
为什么必须是 dev:看插件源码就明白了:
// src/host.js
const response = await fetch(`${ENDPOINT}${path}`, {
...requestInit,
headers: { 'X-OpenViking-Actor-Peer': 'deepseek-harness', ...(requestInit.headers || {}) },
});
插件转发时只发 X-OpenViking-Actor-Peer,不带任何 API Key。所以:
auth_mode: "dev"(仅监听本机、不要求 Key)✅ 正好匹配- 设了
root_api_key会自动切到api_key模式 → 插件立刻 401 ✗
推论:官方的远程 OpenViking 服务插件也接不上,除非对端开了免鉴权。
配置不允许未知字段,字段名写错会拒绝启动。
4.6 验收
curl -s http://127.0.0.1:1933/health # {"status":"ok","healthy":true,"version":"0.4.20","auth_mode":"dev"}
curl -s http://127.0.0.1:1933/ready # {"status":"ready","checks":{...,"embedding":"ok"}}
实测冷启动约 18 秒(首次加载 GGUF 模型);/ready 里看到 embedding: ok 才说明 GGUF 与 llama-cpp-python 都对上了。
最后建默认知识库(10 个):最省事的做法就是刷新一次知识库页面—— 页面挂载时会自动发 Promise.all([knowledgeApi.bootstrap(DEFAULT_LIBRARIES), knowledgeApi.health()])。
⚠️ 传
{"libraries":[]}一个库都不会建。host.js的 bootstrap 是遍历这个数组 逐个mkdir的——空数组只会创建根目录。这是我第一版手册里写错、后来实测纠正的地方。 正确定义是 10 条,写死在KnowledgeView.jsx里。
4.7 让它在重启后自动可用
服务是前台进程,开机不会自启。做一个启动器 openviking.cmd, 再让 dsh 的启动器兜底拉起它:
netstat -ano | findstr /C:"127.0.0.1:1933 " | findstr /C:"LISTENING" >nul 2>&1
if errorlevel 1 start "OpenViking 知识库服务" /min "%~dp0openviking.cmd"
curl -s -o nul --noproxy "*" --max-time 2 "http://127.0.0.1:1933/health"
这样双击一个图标,两个服务一起起来。
两个 Windows 编码坑(都是实测踩出来的): ⚠️
.cmd里的中文必须用 GBK 写(cmd.exe 按系统代码页 936 读批处理), UTF-8 的中文会变乱码;文件开头加chcp 936 >nul 2>&1。 另外timeout /t N在 stdio 被重定向时会直接报错退出,循环等待改用ping -n N+1 127.0.0.1 >nul。 ⚠️.ps1里的中文必须用带 BOM 的 UTF-8 写(utf-8-sig), 否则 PowerShell 5.1 对无 BOM 的 UTF-8 按 ANSI 解析,中文串会破坏引号配对, 实测报「字符串缺少终止符」并整段不执行。
五、故障 B:新建会话报 preset "..." not found
5.1 症状
在插件工作台里新建会话、选好专家和技能、发出第一条消息时报:
agent-presets: preset "up-renewal-policy" not found (available: standard, ptc, minimal, cordis)
这是插件的打包缺陷,不是你的配置问题。
5.2 根因:硬编码在前端,但包里没带
插件把 preset id 硬编码在前端:
// src/config/branding.js
export const AGENT = { id: 'U-E1', name: '政策咨询推送与解读专家',
preset: 'up-renewal-policy', skill: 'u-policy' };
export const BUSINESS_AGENTS = {
urban: AGENT,
overseas: { id: 'S1', name: '研判决策专家',
preset: 'up-renewal-research', skill: 'o-s1' },
};
而 package.json 的 files 字段是:
"files": ["lib/", "src/", "skills/", "scripts/", "README.md", "INSTALL.md"]
没有任何 presets/。 换句话说:打包者本机有自己的 ~/.dsh/.agent-presets/, 但没打进包里。INSTALL.md 也只交代了 Skills 与 OpenViking,这条链是断的。
调用点在 send() 里:
const preset = summary?.projectionValues?.agentPreset;
if (sessionState.blank && preset !== agent.preset) {
const selected = await ctx.remote.agentPresets.select(current, agent.preset); // ← 这里抛
}
所以报错只发生在空白会话的第一条消息。已经固定成别的 preset 的会话会走另一条分支, 报「当前 Session 已固定为其他 Agent,请新建 DSH Session 后使用」。
排错时的一个坑:变量名是驼峰
agentPreset,用小写preset搜代码会漏掉调用点。
5.3 修法:补建两个 preset(抄 standard,只换 persona)
dsh 的约定很清晰——目录名就是 preset id:
<DSH_HOME>/.agent-presets/
├── up-renewal-policy/ ← 目录名 = preset id,必须严格一致
│ ├── preset.yml ← 只有 name / description / order
│ └── agent.cordis.yml ← 组成文件(254 行)
└── up-renewal-research/
├── preset.yml
└── agent.cordis.yml
关键做法:以 随包交付、已验证可挂载的 standard preset 为底座,只替换 persona 那一段。 本次实测生成的文件里,尾部 226 行与 standard 逐行一致(已 diff 核对)。
为什么这样做?因为 preset 就是一份插件组合清单——工具集(shell、文件读写检索、 Skills、网页检索、待办、提问、子代理、工作流、计划模式、上下文压缩)全在里面。 逐行照抄已验证可用的 standard、只换 persona,能一次到位且不引入任何挂载期未知数。
persona 行的 schema 是定死的四个字段:
- id: persona
name: '@deepseek-ai/dsh-persona'
config:
prefix: |- # 必填。用 | 字面块保留换行
你是…… # (用 > 折叠块会把 bullet 列表挤成一整段)
suffix: 当前工作目录:{{cwd}}。 # 可选
# complete: true # 可选:让 prefix 成为完整系统提示词(会压掉 suffix 与所有其他段)
# includeRuntimeContext: false # 可选:关掉动态运行上下文快照
{{...}} 是严格插值,未注册的变量会报错({{cwd}} 是有效的)。
5.4 为什么不用重启 dsh
dsh-agent-presets 的 discovery 不做记忆化,list() / resolve() 每次调用都重扫根目录。 源码原话:
"Discovery re-reads the roots on every call so a preset authored while the process is running is visible without a restart."
而且 resolvedRoots 在构造期就无条件把 <DSH_HOME>/.agent-presets 拼进列表 (不检查目录是否存在),目录晚建也不影响。
→ 补完 preset 直接刷新页面就生效,不需要重启 dsh web。 这条省掉一次瞎折腾。
5.5 验证:别只看文件在不在
文件在但组成非法一样会失败。权威做法是用 dsh 自己的 discovery 模块跑判定 (直接 import 它的 scanRoot / discoverPresets),覆盖 YAML 方言、行结构、 每个 name 指向的包能否解析:
=== 断言:插件引用的 preset 是否都在 roster 里且健康 ===
[PASS] up-renewal-policy → trust=user 显示名=城策 · 政策咨询与解读
[PASS] up-renewal-research → trust=user 显示名=城策 · 海外项目研判
两个参数类型不一致,很容易写错:
scanRoot(root, harnessBase)的root.path要的是普通文件系统路径(传file://URL 会被拼成乱路径、静默返回空列表); 而harnessBase反过来要的是 URL。 一定要带对照组:把随包的 4 个内置 preset 一起扫,它们也必须健康。 如果连内置的都报 broken,那是参数传错了,不是你的 preset 有问题。⚠️ 但 discovery 判不了"挂载期抛错":它能确认模块可解析, 但插件加载后抛异常只会在真实创建会话时失败。所以"全部健康"是必要不充分条件。
六、故障 C:启动即退出,报 DataDirectoryLocked
6.1 症状:报错点名了一个"嫌疑人"
某天早上双击图标启动,OpenViking 窗口一闪就退,结尾是:
openviking.utils.process_lock.DataDirectoryLocked: Another OpenViking process
(PID 6628) is already using the data directory 'C:\Users\<你>\.openviking\data'.
Running multiple OpenViking instances on the same data directory causes silent
storage contention and data corruption.
...
[openviking] 服务已退出,errorlevel=3
报错措辞很唬人——"静默的存储争用与数据损坏"。但它指认错了人。
6.2 先验身份,别急着去结束那个 PID
先查那个 PID 到底是谁:
Get-CimInstance Win32_Process -Filter "ProcessId=6628" |
Select-Object Name, ExecutablePath, CreationDate, ParentProcessId
实测结果:
| 事实 | 值 |
|---|---|
报错点名的 PID 6628 | XLSmartService.exe(迅雷的服务) |
| 它的出生时间 | 2026-09-16 09:04:15(今早开机后启动) |
| 锁文件的写入时间 | 2026-09-15 15:34:53 |
1933 端口 | 空的——服务其实压根没跑 |
锁比这个进程早了 17 个半小时,不可能是它写的。
6.3 根因:上游代码的平台不对称
OpenViking 用数据目录下的 .openviking.pid 做进程级互斥,防止两个实例同时写同一份 存储。逻辑在 openviking/utils/process_lock.py 的 _is_pid_alive(pid):
| 平台 | 判据 |
|---|---|
| Linux | 拿到 PID 后再读 /proc/<pid>/cmdline,校验它是不是真的 OpenViking(源码注释写明是修 PID 复用误判) |
| Windows | 只判断「这个 PID 有没有进程」,不做任何身份校验 |
同一个 PID 复用的坑,作者在 Linux 上修了,Windows 上没修。 于是这条路径必然踩:
- 非正常退出(关机 / 任务管理器强杀 / 断电)→
atexit注册的清理回调没跑到 → 锁文件残留; - 系统重启后,某个毫不相干的进程(这次是迅雷)拿到了同一个 PID;
- 下次启动读到锁里的 PID →
_is_pid_alive()返回 True(确实有这个进程在)→ 判定「已有实例在跑」→ 抛异常拒绝启动。
这是上游 bug,不是你的配置问题。 三条判据任一条成立,都说明"另有一个实例在跑" 这个前提是假的:报的 PID 与 OpenViking 无关 / 端口是空的 / 锁文件时间早于该进程。
6.4 修法:双重判据,然后做成自愈
手工删掉锁能立刻恢复,但这个坑每次非正常关机都会重现,所以值得做成自愈。
新增 ov-stale-lock.py,判据是双重的,两条同时成立才敢删:
| 判据 | 内容 | 它在证明什么 |
|---|---|---|
| A · 端口 | ov.conf 里的端口(默认 1933)无人监听 | 不存在正在服务的实例 |
| B · 身份 | 锁里的 PID,其进程镜像名不是 OpenViking | 那只是个复用了 PID 的无关进程 |
任一条不符 → 不动锁,以退出码 2 退出,退回人工判断。 宁可让你多看一眼,也不冒数据损坏的风险——报错原文特意强调了 "silent storage contention and data corruption"。
python ov-stale-lock.py # 诊断(只读,退出码 1 = 发现陈旧锁)
python ov-stale-lock.py --fix # 清理(先把旧锁备份成 .openviking.pid.stale-<时间戳>)
诊断模式的输出——下面是故障态下逐字抓的(复现时造的假锁 PID 恰好也是 6628):
====================================================================
OpenViking 陈旧数据目录锁检测
====================================================================
配置 : C:\Users\Ace\.openviking\ov.conf
数据目录 : C:/Users/Ace/.openviking/data
服务端点 : 127.0.0.1:1933
锁文件 : C:/Users/Ace/.openviking/data\.openviking.pid
[判据 A · 端口] 127.0.0.1:1933 监听中 = False
[判据 B · 锁文件] PID=6628 写入时间=2026-09-16 17:01:44
该 PID 当前镜像 = XLSmartService.exe
是否 OpenViking = False
[结论] 陈旧锁(stale lock)—— 这就是 OpenViking 起不来、报
DataDirectoryLocked: Another OpenViking process (PID 6628) 的原因。
端口 1933 无人监听 ⇒ 不存在在服务的实例;
PID 6628 的真实身份是「XLSmartService.exe」⇒ 与 OpenViking 无关,只是复用了这个 PID。
[处置] 仅诊断模式,未改动任何文件。确认上面两条后,加 --fix 清理:
<python> ov-stale-lock.py --fix
判据不符时它什么都不改,所以把它放进启动器是安全的。再往前一步,做成自愈—— 插进 openviking.cmd,位置在端口检测之后、启动之前:
rem --- 陈旧锁自检(Windows 专属坑)---
rem 非正常关机 / 强杀时 atexit 清理没跑到,data\.openviking.pid 会残留;
rem 而 Windows 分支只判断「该 PID 有没有进程」,不校验身份 → 误判"已有实例在跑"。
rem 上面已确认端口空闲,这里再按「端口无监听 + 该 PID 不是 OpenViking」双重判据确认。
if exist "%OV_LOCK%" (
echo [openviking] 发现残留的 PID 锁文件,正在确认是否为陈旧锁...
if exist "%OV_LOCK_PY%" (
"%OV_PY%" "%OV_LOCK_PY%" --fix
) else (
echo [openviking] 未找到锁检测脚本,跳过自检: %OV_LOCK_PY%
)
echo.
)
以后开机双击 dsh 图标就行,不需要人工介入。 dsh-web.cmd 不用改——它是 start 调 openviking.cmd,自动继承新逻辑。
备份文件名带时间戳(
.openviking.pid.stale-<YYYYMMDD-HHMMSS>),里面只有一个 PID、 不到 10 字节,确认服务正常后可以随手删掉。
6.5 实测:造一个一模一样的锁,走完整流程
不满足于"看着应该能自愈",把整条链路跑了一遍(这次的可复现步骤见下):
| 步骤 | 结果(实测) |
|---|---|
| 停掉服务 → 确认 1933 无人监听 | ✅ |
挑一个毫不相干的存活进程当假锁 → 写入 .openviking.pid | 选中 XLSmartService.exe(PID 6628) |
| 跑诊断模式 | 判 [陈旧锁],退出码 1,且未改动任何文件 ✅ |
跑 --fix | 备份 → .openviking.pid.stale-20260916-170151,然后删除原锁 ✅ |
脱离沙箱重启服务 → /health 就绪 | 10 秒(本次)/ 6 秒(首次修复时) |
| 新锁内容 | 44196 —— 与 1933 的监听 PID 一致 ✅ |
原启动器已备份为
openviking.cmd.bak-20260916,可随时回滚。
运维上的一个提醒:这是上游的 bug。升级 OpenViking 时值得复查一次 Windows 分支有没有补上身份校验——补上了就可以把自检拿掉。
七、最隐蔽的坑:服务挂在"启动它的那个会话"上
这一节是本文最有价值的部分——因为它平时不报错,只在"你以为一切正常"之后才咬你。
7.1 现象
如果你是在 AI 助手 / 终端的会话里把服务跑起来的(比如全程让助手帮你装、帮你启), 会埋一个很隐蔽的雷:
- 助手明明说「服务已就绪、路由全部 200」,过一会儿却
fetch failed/ 连接被拒 - 想清理助手留下的后台任务,却不敢停——一停服务就没了
根因:AI 会话用 Windows Job Object 管理命令的子进程,一条命令结束,整棵进程树就被回收。
我的实测现场就很典型——两个服务的处境完全相反:
| 服务 | 父链顶端 | 结论 |
|---|---|---|
3080 dsh web | explorer.exe(我自己双击快捷方式启的) | ✅ 独立 |
1933 OpenViking | 一路追到 WorkBuddy.exe --serve --session-id ... | ❌ 挂在本会话沙箱上 |
实测把承载它的那个后台任务 kill 掉,1933 立刻释放。后台任务一停,服务就没了。
7.2 怎么判:别用 IsProcessInJob
❌ 别用 IsProcessInJob(h, NULL, &r)——本机实测语义不可靠:对一个已经证明独立的进程 (父链顶端是 explorer、且跨多轮工具调用存活),它仍返回 True,与父链结论直接矛盾。
✅ 可靠判据是「是否属于沙箱根的进程树」:
沙箱根 = sandbox-cli.exe + WorkBuddy.exe --serve --session-id ...
(本机实测:前者后代 7 个、后者后代 17 个,并集 18 个进程)
在树内 → 命令结束 / 会话结束必被回收;不在树内 → 独立。
7.3 修法:让服务挂在资源管理器下
import subprocess
subprocess.Popen([r'C:\Windows\explorer.exe',
r'C:\Users\<你>\.dsh\bin\openviking.cmd'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
explorer 会把请求转给已运行的 shell 实例去执行,新进程的父链顶端是 explorer.exe, 不再属于会话的 job。之后停后台任务、关会话都不影响服务, 而且保留可见的控制台窗口——关窗口 = 停服务,符合直觉。
数据无损:data/vectordb、data/viking、sqlite 均落盘,WAL 自动重放,10 个知识库完好。
父链断 ≠ 有问题:用 explorer 转发启动时,中间那个临时宿主 (
explorer.exe /factory,{...} -Embedding)会很快退出,之后父链就查不到顶端了。 以「是否在沙箱进程树内」为准。
7.4 硬证据:心跳对照实验
判据给结论,心跳实验给证据——同一个脚本两种启动方式,跨两次工具调用看日志是否还在增长:
| 组 | 启动方式 | 结果(实测) |
|---|---|---|
| A | explorer.exe <脚本> | 5 行 → 29 行,仍在写 ✅ 存活 |
| B | 会话内 cmd /c start | 5 行 → 5 行,时间戳停住 ❌ 被回收 |
关键点:必须跨越两次工具调用才能看出差别(同一条命令内两组都活着)。
另外记住:用
run_in_background跑虽然能常驻,但服务仍是挂在会话沙箱上的—— 所以"助手留下的后台任务能不能清"这个问题,答案取决于服务当初是怎么起来的。
八、交付物:5 个只读排障脚本
光有结论不够,我把上面每一节的判定都固化成了脚本。全部只读、不改你的数据, 且去掉了硬编码用户名路径(改为 --dsh-home / 环境变量自适应),可以直接给别人用:
| 脚本 | 回答什么问题 |
|---|---|
chengce-doctor.py | 知识库 / 技能管理页不通,是哪一环坏了? |
verify_presets.mjs | 报 preset not found,我补的 preset 到底对不对? |
make_chengce_presets.py | 帮我把缺的 preset 生成出来(幂等) |
win-proc-tree.py | 清理后台任务会不会把服务一起带走? |
ov-stale-lock.py | 启动即退出报 DataDirectoryLocked,这锁是真是假? |
chengce-doctor.py 是这个工具箱的入口:输出端口状态、OpenViking 直连、 三类插件路由、preset 与 skill 就位情况,最后直接给结论—— "OpenViking 没跑" / "服务端自身异常" / "全部正常" / "403 非同源"。
python chengce-doctor.py # 服务 + 路由 + preset + skill 一次查完
python chengce-doctor.py --dsh-port 3080 --ov-port 1933 --dsh-home "D:/dsh"
node verify_presets.mjs # 只看 preset 是否真的可挂载
python win-proc-tree.py --list-sandbox # 看沙箱根是谁、树里有多少进程
python ov-stale-lock.py # 查陈旧锁(加 --fix 清理,会先备份)
脚本内部显式禁用了系统代理(
ProxyHandler({})),这一点很关键: Windows 上 pythonurllib会读注册表里的系统代理(Clash 这类工具写的), 导致探测127.0.0.1被劫持成502/WinError 10061, 看起来像服务挂了,其实请求根本没发出去。 顺带一提:curl只读环境变量、Node 的fetch也不走系统代理—— 所以只有 python 探测需要这个处理。
两个脚本都在正常态与故障态各跑了一遍做验证——故障态是主动 kill 掉 OpenViking 实测的 (冷启动 18 秒后恢复)。
📎 文件怎么拿:CSDN 博客不支持附件下载,所以我把这 5 个脚本的完整源码内联在文末 附录 B 里(5 段共 1185 行),直接复制存成同名文件即可运行,无需任何改动。 依赖说明:
chengce-doctor.py、win-proc-tree.py、ov-stale-lock.py只用 Python 标准库 (win-proc-tree.py仅用ctypes);verify_presets.mjs需要 Node(它要importdsh 自己的 discovery 模块);make_chengce_presets.py只用标准库。
九、以下三种情况"不是故障",别浪费时间
9.1 选某些专家提示「执行层尚未接入」
城策的 12 位专家里只有 2 位接了真实执行层(availability: 'available' 且 preset 非 null), 其余 10 位是 planned 且 preset: null。选其他专家时插件提示 「该专家的 DSH 执行层尚未接入」——这是设计如此。
9.2 接口返回 403 仅允许本机同源访问
function allowed(req) {
const address = req.socket.remoteAddress;
if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) return false;
return req.headers['sec-fetch-site'] !== 'cross-site';
}
插件刻意只允许本机同源访问。如果你通过反向代理、局域网 IP、或跨站嵌入访问 dsh, 所有接口都会 403。用 http://127.0.0.1:<port>/?token=... 直接访问即可。
9.3 知识库页能打开但列表是空的
两种可能,用体检脚本区分:
/libraries返回 200 且有 10 条 → 只是还没上传文档,正常/libraries返回 502,或/resources返回 200 但resources: []→ OpenViking 没跑(第三类"静默失败型")
十、复盘:可以带走的五条经验
-
先定位"是谁的错",再动手改环境。 这次的三个故障,责任方分别是: 插件发布包漏打目录(故障 B)、插件把硬依赖写成"可选"(故障 A)、 上游 OpenViking 的 Windows 分支漏了校验(故障 C)。 三个都不是"我的配置写错了"。读源码定位责任方的成本,比在自己配置里乱试低一个数量级—— 而且能避免你去"修"一个本来就正确的地方。
-
同一个错误码背后可能有三类行为。
502统一兜底、/resources静默吞错—— 如果只按"错误码"分类,就会把"页面能打开但列表为空"误判成"还没上传文档"。 按接口的源码行为分类,而不是按 HTTP 状态码分类。 -
"能连上端口"不等于"服务独立"。 判断服务会不会被回收,必须看进程树归属, 不能靠"我现在能访问"。而且
IsProcessInJob这类 API 在真机上可能给出假阳性, 最终要用心跳对照实验拿硬证据。 -
把排查结论固化成脚本,而不是文档。 文档会过期、会写错(我第一版手册就把 bootstrap 的用法写错了,实测才发现空数组不建库)。脚本每次跑都是最新的事实, 而且可以直接交给别人。
-
报错信息的措辞是"结论",不是"事实"。
DataDirectoryLocked说"另一个 OpenViking 进程正在使用数据目录",听起来无懈可击——但我一验身份,那个 PID 是迅雷,端口还是空的。 报错里给出的任何具体值(PID、路径、行号)都要自己验一遍,尤其是当它要求你做一个 有风险的动作用户(这里是"删掉看起来被别人持有的锁")。
附一条方法论:我一度相信"cmd 的
rem行里写<pid>会被当成重定向"(网上常见说法), 差点据此去改生产启动器。花 10 秒写了个最小.cmd实测,两种写法的返回码、 stdout、stderr 完全一致——该说法在这种形态下不成立。 凡是"我以为是坑"的假设,先写最小用例跑一遍,再动生产代码。
附录 A:路径与端口速查
| 项 | 默认位置 |
|---|---|
| dsh web | http://127.0.0.1:3080(必须带 ?token=...) |
| OpenViking | http://127.0.0.1:1933 |
| 插件安装位 | <DSH_HOME>/profiles/<profile>/node_modules/<包名>/ |
| 插件注册 | <DSH_HOME>/profiles/<profile>/cordis.patch.yml |
| 内置 skill | <用户目录>/.agents/skills/ |
| 知识库元数据 | <DSH_HOME>/chengce-knowledge.json |
| 用户 preset | <DSH_HOME>/.agent-presets/(目录名 = preset id) |
| 随包 preset | <DSH_HOME>/profiles/node_modules/@deepseek-ai/dsh-agent-presets/presets/ |
| OpenViking 配置 | <用户目录>/.openviking/ov.conf |
| OpenViking 数据目录锁 | <用户目录>/.openviking/data/.openviking.pid(内容是一个 PID) |
| 嵌入模型 | <用户目录>/.cache/openviking/models/bge-small-zh-v1.5-f16.gguf |
插件源码里硬编码的常量(改这些要同步改):
const BASE = '/chengce-knowledge/v1'; // 路由前缀
const ROOT = 'viking://resources/chengce'; // OpenViking 里的库根
const META_FILE = join(homedir(), '.dsh', 'chengce-knowledge.json'); // ⚠️ 硬编码 .dsh
const SKILLS_DIR= join(homedir(), '.agents', 'skills');
⚠️
META_FILE用的是homedir() + '.dsh',不读DSH_HOME环境变量。 如果你把DSH_HOME改到别处,插件的元数据仍会写到~/.dsh/——这是个已知的不一致点, 排查"配置写了但不生效"时要想到它。
最后一句:dsh 目前是开发者预览版,官方明说会有破坏兼容性的变更。 本文的结论对 0.1.5-rc + 插件 1.1.0 + OpenViking 0.4.20 这组版本负责, 升级后请以实测为准——这也正是"把结论写成脚本"比"写成文档"更值钱的原因。
附录 B:5 个排障脚本完整源码(复制即用)
CSDN 博客不支持附件下载,所以把源码直接内联在这里。 把下面 5 段分别存成同名文件即可运行,无需任何改动。 路径全部做了回退(命令行参数 → 环境变量 → 默认值),没有硬编码用户名, 可以直接转给别人用。
| 顺序 | 文件 | 依赖 | 作用 |
|---|---|---|---|
| B.1 | chengce-doctor.py | Python 标准库 | 一键体检,直接给结论 |
| B.2 | verify_presets.mjs | Node | preset 权威判定 |
| B.3 | make_chengce_presets.py | Python 标准库 | 幂等补建 preset |
| B.4 | win-proc-tree.py | 零依赖(ctypes) | 沙箱归属诊断 |
| B.5 | ov-stale-lock.py | Python 标准库 | 陈旧数据目录锁检测 / 清理 |
B.1 chengce-doctor.py
作用:城策插件一键体检:端口 → OpenViking 直连 → 三类插件路由 → preset / skill → 结论。
依赖:只用 Python 标准库。最后一行直接给结论,不用自己读表格。
用法:
python chengce-doctor.py
python chengce-doctor.py --dsh-port 3080 --ov-port 1933 --dsh-home "D:/dsh"
完整源码(233 行 / 10689 字节):
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""城策插件(dsh-client-ui-urban-planning-agent-workbench)一键体检。
为什么需要它:装完城策后常见两种报错长得完全不同、成因却都在"依赖没到位"——
① 知识库 / 技能管理页报 fetch failed(控制台 502)
② 新建会话发第一条消息报 preset "..." not found
本脚本用一条命令把 ① 的故障面切开,并顺带检查 ② 的两个 preset 是否就位。
用法:
python chengce-doctor.py
python chengce-doctor.py --dsh-port 3080 --ov-port 1933 --dsh-home "D:/dsh"
判定原理(来自插件源码 src/host.js):
BASE = /chengce-knowledge/v1,所有分支的 catch 统一 `json(res, 502, {ok,error})`,
所以 **502 永远只等于"插件转发给 OpenViking 失败"**,不代表 dsh 挂了。
其中 /skills /expert-bindings /skill-configs 只读写本地文件,**不碰 OpenViking**;
/health /libraries /resources /upload /search 才会转发 OpenViking。
"""
import argparse
import json
import os
import socket
import sys
import urllib.error
import urllib.request
try: # 中文输出在重定向到文件时也别乱码
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
except Exception: # noqa: BLE001
pass
# 本机没有 HTTP_PROXY 环境变量,但 urllib 在 Windows 上会读注册表里的系统代理
# (FlClash / Clash 这类工具写的),导致探测 127.0.0.1 被劫持成 502 —— 必须显式绕过。
OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
# 直接 await ov(...) 的接口:OpenViking 不在就抛错 → 插件统一 catch 成 502
NEEDS_OV = [
('GET', '/health', '服务健康(同时打 OV 的 /health + /ready)'),
('GET', '/libraries', '知识库列表'),
]
# 源码里给每个 ov() 挂了 .catch(() => []):**恒返回 200**,OV 不在时静默给空数组。
# 所以它不能用来判断 OV 是否健康 —— 反过来,"页面打得开但列表永远空" 可能正是 OV 挂了。
SILENT_ON_FAIL = [
('GET', '/resources?libraryId=policy', '知识库内资源(OV 挂时静默返回空,仍 200)'),
]
# 只读写本地文件的接口(OV 在不在都该 200)
LOCAL_ONLY = [
('GET', '/skills', '读 <agentsHome>/skills'),
('GET', '/expert-bindings', '读 <DSH_HOME>/chengce-knowledge.json'),
('GET', '/skill-configs', '读 <DSH_HOME>/chengce-knowledge.json'),
]
PRESETS = ['up-renewal-policy', 'up-renewal-research']
SKILLS = ['u-policy', 'o-s1', 'o-s1-01', 'o-s1-02', 'o-s1-03', 'o-s1-04', 'o-s1-05', 'o-s1-06']
def port_open(port, host='127.0.0.1'):
sock = socket.socket()
sock.settimeout(1.5)
try:
return sock.connect_ex((host, port)) == 0
finally:
sock.close()
def call(url, method='GET', body=None, timeout=15):
headers = {'User-Agent': 'chengce-doctor', 'Accept': 'application/json'}
data = None
if body is not None:
data = json.dumps(body).encode('utf-8')
headers['content-type'] = 'application/json'
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with OPENER.open(req, timeout=timeout) as resp:
return resp.status, resp.read(3000).decode('utf-8', 'replace')
except urllib.error.HTTPError as exc:
return exc.code, exc.read(3000).decode('utf-8', 'replace')
except Exception as exc: # noqa: BLE001
return None, '%s: %s' % (type(exc).__name__, exc)
def line(mark, label, detail):
print(' %-4s %-42s %s' % (mark, label, detail))
def probe_group(title, base, items, results):
print()
print('=' * 88)
print(title)
print('=' * 88)
for method, path, why in items:
code, text = call(base + path, method, None)
flat = ' '.join(text.split())
mark = 'OK' if code == 200 else 'FAIL'
line(mark, '%s %s' % (method, path[:36]), 'HTTP %-5s %s' % (code, flat[:96]))
print(' └ %s' % why)
results.append((path, code, flat))
return results
def main():
ap = argparse.ArgumentParser(description='城策插件一键体检')
ap.add_argument('--dsh-port', type=int, default=3080, help='dsh web 端口,默认 3080')
ap.add_argument('--ov-port', type=int, default=1933, help='OpenViking 端口,默认 1933')
ap.add_argument('--dsh-home', default=os.environ.get('DSH_HOME') or os.path.join(os.path.expanduser('~'), '.dsh'),
help='DSH_HOME,默认 ~/.dsh')
ap.add_argument('--agents-home', default=os.environ.get('DSH_AGENTS_HOME') or os.path.join(os.path.expanduser('~'), '.agents'),
help='技能根目录,默认 ~/.agents')
args = ap.parse_args()
dsh = 'http://127.0.0.1:%d' % args.dsh_port
ov = 'http://127.0.0.1:%d' % args.ov_port
base = dsh + '/chengce-knowledge/v1'
print('城策插件体检')
print(' dsh web %s' % dsh)
print(' OpenViking %s' % ov)
print(' DSH_HOME %s' % args.dsh_home)
# ---------- 1. 端口 ----------
ov_up = port_open(args.ov_port)
dsh_up = port_open(args.dsh_port)
print()
print('=' * 88)
print('1. 端口')
print('=' * 88)
line('OK' if dsh_up else 'FAIL', 'dsh web 127.0.0.1:%d' % args.dsh_port, '监听中' if dsh_up else '未监听')
line('OK' if ov_up else 'FAIL', 'OpenViking 127.0.0.1:%d' % args.ov_port, '监听中' if ov_up else '未监听')
# ---------- 2. OpenViking 直连 ----------
print()
print('=' * 88)
print('2. OpenViking 服务端直连')
print('=' * 88)
if ov_up:
for path in ('/health', '/ready'):
code, text = call(ov + path)
flat = ' '.join(text.split())
mark = 'OK' if code == 200 else 'FAIL'
line(mark, 'GET %s' % path, 'HTTP %-5s %s' % (code, flat[:96]))
else:
line('SKIP', '(服务未监听,跳过)', '先启动 OpenViking 再看这一节')
if not dsh_up:
print()
print(' 结论:dsh web 没在监听,先启动它(插件路由由 dsh 提供)。')
return 1
# ---------- 3. 插件路由 ----------
needs = probe_group('3. 插件路由 —— 直接转发 OpenViking(OV 没跑就必然 502)', base, NEEDS_OV, [])
silent = probe_group('3b. 插件路由 —— 静默失败型(源码带 .catch,恒 200,要看内容)',
base, SILENT_ON_FAIL, [])
local = probe_group('4. 插件路由 —— 只读写本地文件(OV 在不在都该 200)', base, LOCAL_ONLY, [])
# ---------- 5. preset / skill / 元数据 ----------
print()
print('=' * 88)
print('5. agent preset 与内置 skill')
print('=' * 88)
preset_root = os.path.join(args.dsh_home, '.agent-presets')
preset_ok = True
for pid in PRESETS:
d = os.path.join(preset_root, pid)
comp = os.path.join(d, 'agent.cordis.yml')
meta = os.path.join(d, 'preset.yml')
good = os.path.isfile(comp) and os.path.isfile(meta)
preset_ok &= good
line('OK' if good else 'FAIL', pid,
'agent.cordis.yml + preset.yml 就位' if good else '缺文件(目录:%s)' % d)
if not preset_ok:
print(' └ 权威判定请另跑:node verify_presets.mjs')
skills_root = os.path.join(args.agents_home, 'skills')
hits = 0
for sid in SKILLS:
if os.path.isfile(os.path.join(skills_root, sid, 'SKILL.md')):
hits += 1
line('OK' if hits == len(SKILLS) else 'FAIL', '内置 skill %d/%d' % (hits, len(SKILLS)),
skills_root if hits == len(SKILLS) else '缺少部分 skill,postinstall 可能被跳过')
meta_file = os.path.join(args.dsh_home, 'chengce-knowledge.json')
if os.path.isfile(meta_file):
try:
m = json.load(open(meta_file, encoding='utf-8'))
libs = m.get('libraries') or []
line('OK', 'chengce-knowledge.json', '%d 个知识库已登记,%d 位专家绑定'
% (len(libs), len(m.get('expertBindings') or {})))
except Exception as exc: # noqa: BLE001
line('FAIL', 'chengce-knowledge.json', '解析失败: %s' % exc)
else:
line('FAIL', 'chengce-knowledge.json', '不存在(知识库页首次打开会自动创建,也可 POST /bootstrap)')
# ---------- 6. 结论 ----------
needs_codes = dict((p, c) for p, c, _ in needs)
local_codes = dict((p, c) for p, c, _ in local)
lib_code = needs_codes.get('/libraries')
# 静默型:返回 200 但 body 里列表为空 → 源码那层 catch 吞掉了 OpenViking 的错误
silent_empty = any('"resources":[]' in t.replace(' ', '') for _, _, t in silent)
print()
print('=' * 88)
print('结论')
print('=' * 88)
if not ov_up and lib_code == 502:
print(' ▸ OpenViking 没跑,而插件依赖它 —— 这就是「fetch failed / 502」的原因。')
print(' 修:启动 OpenViking 服务(见手册第 3 节),再刷新页面。')
elif ov_up and lib_code == 502:
print(' ▸ OpenViking 在监听,但转发仍失败 —— 问题在服务端自身。')
print(' 查:OV 启动日志是否报 EmbeddingConfigurationError(缺本地嵌入模型或 llama-cpp-python)。')
elif lib_code == 200 and all(c == 200 for c in local_codes.values()):
print(' ▸ 插件路由全部正常 —— 知识库 / 技能管理页应当可用。')
elif lib_code is None:
print(' ▸ 连不上 dsh 的插件路由 —— 确认插件已注册到 profile 的 cordis.patch.yml 并重启过 dsh。')
elif any(c == 403 for c in local_codes.values()):
print(' ▸ 返回 403「仅允许本机同源访问」—— 你在通过代理 / 远程地址访问 dsh。')
print(' 修:用 http://127.0.0.1:<port>/?token=... 直接访问,别走反代。')
else:
print(' ▸ 状态混合,见上面各行的 FAIL。')
if silent_empty and not ov_up:
print(' ▸ 注意:/resources 仍是 200 但内容为空 —— 它是"静默失败型"(源码里带 catch),')
print(' 所以"知识库页打得开、里面永远没东西"同样是 OpenViking 没跑的表现。')
if not preset_ok:
print(' ▸ 另有 preset 缺失 —— 新建会话发第一条消息会报 preset "..." not found。')
print(' 修:见手册第 4 节,补建到 <DSH_HOME>/.agent-presets/ 下。')
return 0
if __name__ == '__main__':
sys.exit(main())
B.2 verify_presets.mjs
作用:preset 权威判定:直接 import dsh 自己的 discovery 模块,判定与运行时完全一致。
依赖:需要 Node(因为要 import @deepseek-ai/dsh-agent-presets)。 会自动从插件源码里提取 preset id,插件升级换了 id 它也跟着变。
用法:
node verify_presets.mjs
node verify_presets.mjs --dsh-home "D:/dsh"
完整源码(125 行 / 5344 字节):
// 验证城策插件硬编码引用的 agent preset 是否真的可用。
//
// 为什么不能只看文件在不在:preset 是「插件组合」,文件存在 ≠ 组成合法 ≠ 模块能解析。
// 所以这里不重新实现扫描规则,而是直接调用 dsh-agent-presets 自己的
// scanRoot / discoverPresets —— 判定与运行时完全一致(涵盖 YAML 方言、行结构、模块可解析性)。
//
// 用法:
// node verify_presets.mjs
// node verify_presets.mjs --dsh-home "D:/dsh" --plugin "D:/dsh/profiles/web/node_modules/dsh-client-ui-urban-planning-agent-workbench"
import { pathToFileURL } from 'node:url';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
// ---------- 参数 ----------
const argv = process.argv.slice(2);
function arg(name, fallback) {
const i = argv.indexOf('--' + name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
}
const DSH_HOME = (arg('dsh-home', process.env.DSH_HOME) || path.join(os.homedir(), '.dsh')).replace(/\\/g, '/');
const PLUGIN_NAME = 'dsh-client-ui-urban-planning-agent-workbench';
function firstExisting(candidates) {
return candidates.find((p) => fs.existsSync(p));
}
// dsh-agent-presets 可能装在 profiles 根或某个 profile 下(hoisted / 非 hoisted 都可能)
const PROFILES = path.join(DSH_HOME, 'profiles');
const profileDirs = fs.existsSync(PROFILES)
? fs.readdirSync(PROFILES, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
: [];
const PRESET_PKG = firstExisting([
path.join(PROFILES, 'node_modules/@deepseek-ai/dsh-agent-presets'),
...profileDirs.map((p) => path.join(PROFILES, p, 'node_modules/@deepseek-ai/dsh-agent-presets')),
]);
if (!PRESET_PKG) {
console.error('找不到 @deepseek-ai/dsh-agent-presets —— 检查 --dsh-home 是否正确:' + DSH_HOME);
process.exit(2);
}
// harnessBase 必须是 URL;要给「某个 profile 目录」,packageInstalled 会从它逐级向上找 node_modules
const HARNESS_DIR = firstExisting([
path.join(PROFILES, 'web'),
...profileDirs.map((p) => path.join(PROFILES, p)),
]);
const HARNESS_BASE = pathToFileURL(HARNESS_DIR + '/').href;
// ---------- 找出插件实际引用的 preset id(不硬编码,跟着插件源码走)----------
const pluginDir = arg('plugin', firstExisting([
path.join(PROFILES, 'web/node_modules', PLUGIN_NAME),
...profileDirs.map((p) => path.join(PROFILES, p, 'node_modules', PLUGIN_NAME)),
]));
const WANT = [];
if (pluginDir && fs.existsSync(pluginDir)) {
for (const rel of ['src/config/branding.js', 'src/config/experts.js']) {
const f = path.join(pluginDir, rel);
if (!fs.existsSync(f)) continue;
const text = fs.readFileSync(f, 'utf8');
for (const m of text.matchAll(/preset:\s*'([^']+)'/gu)) {
if (m[1] !== 'null' && !WANT.includes(m[1])) WANT.push(m[1]);
}
}
}
// ---------- 跑 dsh 自己的 discovery ----------
const d = await import(pathToFileURL(path.join(PRESET_PKG, 'lib/types/discovery.js')).href);
// 注意:scanRoot 的 root.path 要「普通文件系统路径」(内部 resolve + 展开 ~),传 file:// 会静默返回空;
// harnessBase 反过来要 URL。这两个参数类型不一致,是最容易写错的地方。
const roots = [
{ path: path.join(PRESET_PKG, 'presets').replace(/\\/g, '/'), trust: 'system' }, // 对照组:随包 4 个内置
{ path: path.join(DSH_HOME, '.agent-presets'), trust: 'user' },
];
console.log('=== discovery 常量 ===');
console.log(' USER_PRESET_DIR =', d.USER_PRESET_DIR);
console.log(' SHIPPED_PRESET_ROOT =', d.SHIPPED_PRESET_ROOT);
console.log(' harnessBase =', HARNESS_BASE);
console.log(' 插件目录 =', pluginDir || '(未找到)');
console.log();
console.log('=== 逐根扫描 ===');
for (const root of roots) {
const found = await d.scanRoot(root, HARNESS_BASE);
console.log(`[${root.trust}] ${root.path}`);
if (!found.length) console.log(' (无)');
for (const p of found) {
console.log(` ${p.id.padEnd(22)} trust=${String(p.trust).padEnd(6)} order=${String(p.order ?? '-').padEnd(4)} name=${p.name ?? '(无)'}`);
console.log(` 健康: ${p.broken === undefined ? 'OK' : 'BROKEN: ' + p.broken}`);
}
console.log();
}
const roster = await d.discoverPresets(roots, HARNESS_BASE);
console.log('=== 合并 roster ===');
console.log('共', roster.length, '个:', roster.map((p) => p.id).join(', '));
console.log();
if (!WANT.length) {
console.log('未从插件源码里提取到 preset 引用,跳过断言。');
process.exit(0);
}
console.log('=== 断言:插件引用的 preset 是否都在 roster 里且健康 ===');
let ok = true;
for (const id of WANT) {
const hit = roster.find((p) => p.id === id);
if (!hit) {
console.log(` [FAIL] ${id} —— 不在 roster 中(新建会话会报 preset "${id}" not found)`);
ok = false;
} else if (hit.broken !== undefined) {
console.log(` [FAIL] ${id} —— 存在但 broken: ${hit.broken}`);
ok = false;
} else {
console.log(` [PASS] ${id} → trust=${hit.trust} 显示名=${hit.name}`);
}
}
console.log();
console.log(ok
? '结论:插件引用的 preset 均可被发现且组成合法。'
: '结论:仍有问题,见上方 FAIL。补建方法见手册第 4 节。');
process.exit(ok ? 0 : 1);
B.3 make_chengce_presets.py
作用:幂等补建缺失的两个 agent preset(以随包的 standard 为底座,只替换 persona 行)。
依赖:只用 Python 标准库。可重复运行——已存在的同名目录会被覆盖。
用法:
python make_chengce_presets.py
python make_chengce_presets.py --dsh-home "D:/dsh"
完整源码(192 行 / 9316 字节):
# -*- coding: utf-8 -*-
"""补齐城策插件缺失的两个 agent preset(修 preset "..." not found)。
背景
----
城策工作台插件(dsh-client-ui-urban-planning-agent-workbench@1.1.0)的
src/config/branding.js 硬编码了 preset id:
up-renewal-policy ← U-E1 政策咨询推送与解读专家
up-renewal-research ← S1 海外项目研判决策专家
但发布包 package.json 的 files 字段只有 lib/ src/ skills/ scripts/ ——
**没有任何 presets/**。这两个 preset 从未随包交付。
于是插件在空白会话首次发送时调用
ctx.remote.agentPresets.select(sessionId, agent.preset)
服务端在 roster 里找不到该 id,抛:
agent-presets: preset "up-renewal-policy" not found (available: standard, ptc, minimal, cordis)
修法
----
按 dsh 的既定约定(discovery.js 的 USER_PRESET_DIR = '.agent-presets',根为 <DSH_HOME>/.agent-presets)
在本机补建这两个 preset。组成文件以随包交付、已验证可挂载的 standard preset 为底座,
**只替换 persona 行**,保证工具集完整(shell / fs / 检索 / Skills / 网页 / 子代理 / 计划 / 压缩),
且不引入任何挂载期未知数。
用法
----
python make_chengce_presets.py
python make_chengce_presets.py --dsh-home "D:/dsh"
覆盖行为:已存在同名目录会被覆盖(幂等,可重复跑)。
"""
import argparse
import glob
import io
import os
import sys
# ── 两个 preset 的定义 ────────────────────────────────────────────────────────
POLICY_PERSONA = """\
你是「城策」工作台的城市更新政策咨询推送与解读专家,负责政策资讯的采集、去重、\
时效过滤、适用性解读,以及政策周报与重点政策汇报。
工作准则:
- 结论前置,先给判断再给依据,不要用铺垫开场。
- 每条政策结论都必须给出信息来源:发文机关、文号、发布日期、适用对象、有效期。缺哪项就写明缺哪项。
- 区分「已核实」与「待核实」。无法确认的字段标注 [待核实],不要补全成看似确定的表述。
- 时效优先:先确认政策是否现行有效,再判断适用性;已废止、已到期或被新文件替代的必须标注。
- 去重以「发文机关 + 文号」为主键;同一政策的多篇转载合并为一条,保留最权威的来源。
- 涉及金额、比例、期限、口径等关键内容时保留原文表述,不做换算、不做推断。
- 可调用 u-policy 技能,以及知识库检索与网页检索工具。引用知识库内容时给出库名与文档标识。
- 交付物默认使用简体中文与 Markdown;表格优先于长段落。"""
RESEARCH_PERSONA = """\
你是「城策」工作台的海外项目前期研判决策专家(S1),是项目前期第一道决策闸门。
工作准则:
- 结论前置:先给出「是否跟进」的明确判断,再给证据与不确定性。
- 六个研判面向必须逐个交代:机会、准入、市场、风险、价值、推进建议。缺证据的面向显式记为缺口,不臆测。
- 关键事实必须可追溯:给出信息来源与出处;多源冲突时并列呈现,并说明取舍理由。
- 严格区分「事实」「推断」「假设」三类内容,分别标注,不要混写成一种语气。
- 高风险项给出触发条件与应对建议,不做无依据的乐观陈述。
- 可调用 o-s1 系列技能(机会战略、宏观可行性、市场、风险、价值、推进建议)以及知识库检索与网页检索工具。引用知识库内容时给出库名与文档标识。
- 你负责备齐证据与草案,最终结论由主管或领导确认;请明确标出需要人工决策的节点。
- 交付物默认使用简体中文与 Markdown;表格优先于长段落。"""
PRESETS = [
{
'id': 'up-renewal-policy',
'name': '城策 · 政策咨询与解读',
'description': '面向城市更新的政策资讯采集、去重、时效过滤与适用性解读。'
'产出带来源的政策卡、政策周报与重点政策汇报。',
'order': 10,
'persona': POLICY_PERSONA,
'banner': '城市更新政策咨询推送与解读专家(U-E1)',
},
{
'id': 'up-renewal-research',
'name': '城策 · 海外项目研判',
'description': '海外项目前期机会、准入、市场、风险与价值研判,'
'输出是否跟进结论与 S2—S6 阶段路由建议。',
'order': 11,
'persona': RESEARCH_PERSONA,
'banner': '海外项目前期研判决策专家(S1)',
},
]
HEADER = """\
# 城策工作台的 agent preset:{banner}
#
# 本文件由脚本补建,因为城策插件(dsh-client-ui-urban-planning-agent-workbench@1.1.0)
# 硬编码了 preset id '{pid}',但发布包未携带该 preset —— 装机后选择专家并在空白
# 会话发送消息,会报 agent-presets: preset "{pid}" not found。
#
# 组成与随包的 standard preset 等价(仅 persona 行不同),因此工具集完整:
# shell、文件读写与检索、Skills、网页检索、待办、提问、子代理、工作流、计划模式、上下文压缩。
#
# 位置:<dshHome>/.agent-presets/{pid} —— 目录名即 preset id。
# 本 preset 无需迁移数据;正在运行的会话停留在它启动时的组成上,新建会话才采用新版本。
"""
PERSONA_BLOCK = """\
- id: persona
name: '@deepseek-ai/dsh-persona'
config:
suffix: 当前工作目录:{{{{cwd}}}}。
prefix: |-
{prefix}
"""
def indent(text, spaces):
pad = ' ' * spaces
return '\n'.join(pad + line if line.strip() else '' for line in text.split('\n'))
def find_standard(dsh_home):
"""定位随包交付的 standard preset 组成文件。"""
candidates = [
os.path.join(dsh_home, 'profiles', 'node_modules', '@deepseek-ai',
'dsh-agent-presets', 'presets', 'standard', 'agent.cordis.yml'),
os.path.join(dsh_home, 'profiles', 'web', 'node_modules', '@deepseek-ai',
'dsh-agent-presets', 'presets', 'standard', 'agent.cordis.yml'),
]
candidates += glob.glob(os.path.join(
dsh_home, 'profiles', '*', 'node_modules', '@deepseek-ai',
'dsh-agent-presets', 'presets', 'standard', 'agent.cordis.yml'))
for path in candidates:
if os.path.isfile(path):
return path
return None
def build_composition(preset, standard_text):
"""以 standard 为底座,替换 persona 行。"""
anchor = '- id: agent-instructions'
idx = standard_text.index(anchor) # 这一行之后全部照抄(含 agent-instructions 等)
new_header = HEADER.format(banner=preset['banner'], pid=preset['id'])
persona = PERSONA_BLOCK.format(prefix=indent(preset['persona'], 6))
return new_header + persona + standard_text[idx:]
def main():
ap = argparse.ArgumentParser(description='补齐城策插件缺失的两个 agent preset')
ap.add_argument('--dsh-home', default=os.environ.get('DSH_HOME') or os.path.join(os.path.expanduser('~'), '.dsh'))
args = ap.parse_args()
dsh_home = args.dsh_home
standard_path = find_standard(dsh_home)
if not standard_path:
print('找不到随包的 standard preset 组成文件,请确认 --dsh-home 是否正确:%s' % dsh_home)
print('可找的位置:<DSH_HOME>/profiles[/<profile>]/node_modules/@deepseek-ai/dsh-agent-presets/presets/standard/agent.cordis.yml')
return 1
print('底座组成:%s' % standard_path)
standard_text = io.open(standard_path, encoding='utf-8').read()
user_root = os.path.join(dsh_home, '.agent-presets')
os.makedirs(user_root, exist_ok=True)
print('用户 preset 根目录:%s' % user_root)
print()
for preset in PRESETS:
target = os.path.join(user_root, preset['id'])
print('[%s] %s' % ('覆盖' if os.path.isdir(target) else '新建', target))
os.makedirs(target, exist_ok=True)
# preset.yml —— 只放展示元数据;没有 id / trust 字段(那两个由目录名与所在根决定)
meta = 'name: %s\ndescription: %s\norder: %d\n' % (
preset['name'], preset['description'], preset['order'])
with io.open(os.path.join(target, 'preset.yml'), 'w', encoding='utf-8', newline='\n') as fh:
fh.write(meta)
comp = build_composition(preset, standard_text)
with io.open(os.path.join(target, 'agent.cordis.yml'), 'w', encoding='utf-8', newline='\n') as fh:
fh.write(comp)
print(' preset.yml %6d bytes' % os.path.getsize(os.path.join(target, 'preset.yml')))
print(' agent.cordis.yml %6d bytes (%d 行)'
% (os.path.getsize(os.path.join(target, 'agent.cordis.yml')), comp.count('\n') + 1))
print()
print('完成。dsh-agent-presets 的 discovery 不做记忆化,会重扫根目录,')
print('所以**不需要重启 dsh web** —— 刷新页面即可生效。')
print('权威校验:node verify_presets.mjs')
return 0
if __name__ == '__main__':
sys.exit(main())
B.4 win-proc-tree.py
作用:进程树 / 沙箱归属诊断:判断服务会不会随会话被回收,决定后台任务能不能放心清。
依赖:零第三方依赖,只用 ctypes 调 Win32 API。含 --explain-heartbeat 打印心跳对照实验做法。
用法:
python win-proc-tree.py # 默认查 1933 与 3080
python win-proc-tree.py --list-sandbox # 列出沙箱根进程及树规模
python win-proc-tree.py --explain-heartbeat # 心跳对照实验的做法与实测结果
完整源码(302 行 / 11388 字节):
# -*- coding: utf-8 -*-
"""win-proc-tree.py —— Windows 进程树 / 沙箱归属诊断(无外部依赖,只用 ctypes)
## 解决什么问题
本机的 WorkBuddy 会话用 **Windows Job Object** 管理命令的子进程:一条命令结束时
整棵进程树被回收。被回收的服务表现为「刚才还通,下一条命令就 10061 / 连接被拒」。
所以日志里那个常驻服务,到底是能活、还是会掉,**只看端口通不通分不出来**。
## 判据(按可靠性排序)
1. **主判据:是否属于沙箱根的进程树**。
沙箱根 = `sandbox-cli.exe` 和命令行含 `--serve` 的 `WorkBuddy.exe`。
目标进程是它们的后代 → 会被回收;不是 → 独立。
2. **佐证:父链顶端**。顶端是 `explorer.exe` / `services.exe` → 独立。
⚠️ 父链会断:用 `explorer.exe` 转发启动时,中间那个临时 shell 宿主进程
(`explorer.exe /factory,{...} -Embedding`)会很快退出,之后父链就查不到了。
所以**父链断裂不等于有问题**,要以上面第 1 条为准。
3. **⚠️ 不要用 `IsProcessInJob(h, NULL, &r)`**。实测在本机语义不可靠:
对 `explorer.exe` 启动、父链已证明独立的进程,它仍报 `True`(与父链结论矛盾)。
4. **最终的硬证据是心跳对照实验**(见 `--explain-heartbeat`):
同一脚本分别用两种方式启动,跨工具调用看谁的日志还在增长。
## 用法
python win-proc-tree.py # 默认查 1933 与 3080
python win-proc-tree.py --port 1933
python win-proc-tree.py --port 3000,8080
python win-proc-tree.py --pid 12345 # 直接查指定 PID
python win-proc-tree.py --list-sandbox # 列出沙箱根进程
python win-proc-tree.py --explain-heartbeat # 打印心跳对照实验步骤
"""
import argparse
import ctypes
import ctypes.wintypes as w
import datetime
import subprocess
import sys
try:
sys.stdout.reconfigure(errors='replace')
except Exception:
pass
k32 = ctypes.WinDLL('kernel32', use_last_error=True)
ntdll = ctypes.WinDLL('ntdll')
TH32CS_SNAPPROCESS = 0x00000002
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
ProcessCommandLineInformation = 60
SHELL_ROOTS = ('explorer.exe', 'services.exe', 'wininit.exe', 'winlogon.exe')
SANDBOX_EXE = ('sandbox-cli.exe',)
class PROCESSENTRY32W(ctypes.Structure):
_fields_ = [
('dwSize', w.DWORD), ('cntUsage', w.DWORD), ('th32ProcessID', w.DWORD),
('th32DefaultHeapID', ctypes.POINTER(ctypes.c_ulong)), ('th32ModuleID', w.DWORD),
('cntThreads', w.DWORD), ('th32ParentProcessID', w.DWORD),
('pcPriClassBase', ctypes.c_long), ('dwFlags', w.DWORD),
('szExeFile', w.WCHAR * 260),
]
def snapshot():
"""{pid: (ppid, exe_name)}"""
snap = k32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
if snap == -1:
return {}
e = PROCESSENTRY32W()
e.dwSize = ctypes.sizeof(PROCESSENTRY32W)
out = {}
ok = k32.Process32FirstW(snap, ctypes.byref(e))
while ok:
out[int(e.th32ProcessID)] = (int(e.th32ParentProcessID), e.szExeFile)
ok = k32.Process32NextW(snap, ctypes.byref(e))
k32.CloseHandle(snap)
return out
def proc_info(pid):
"""返回 (cmdline, create_time);取不到则为 None。"""
h = k32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not h:
return None, None
try:
cmd = None
need = w.ULONG(0)
ntdll.NtQueryInformationProcess(h, ProcessCommandLineInformation, None, 0,
ctypes.byref(need))
if need.value:
buf = ctypes.create_string_buffer(need.value)
if ntdll.NtQueryInformationProcess(h, ProcessCommandLineInformation, buf,
need.value, ctypes.byref(need)) == 0:
length = ctypes.cast(buf, ctypes.POINTER(ctypes.c_ushort))[0]
# 64 位下 UNICODE_STRING 的 Buffer 指针在偏移 8
ptr = ctypes.cast(ctypes.addressof(buf) + 8,
ctypes.POINTER(ctypes.c_void_p))[0]
if ptr and length:
cmd = ctypes.wstring_at(ptr, length // 2)
created = None
tc, te, tk, tu = (w.FILETIME(), w.FILETIME(), w.FILETIME(), w.FILETIME())
if k32.GetProcessTimes(h, ctypes.byref(tc), ctypes.byref(te),
ctypes.byref(tk), ctypes.byref(tu)):
v = (tc.dwHighDateTime << 32) | tc.dwLowDateTime
if v:
created = datetime.datetime.fromtimestamp(v / 10_000_000 - 11644473600)
return cmd, created
finally:
k32.CloseHandle(h)
def clean(s):
return ' '.join(s.split()) if s else ''
def sandbox_roots(snap):
"""沙箱根:sandbox-cli.exe,以及命令行含 --serve 的 WorkBuddy.exe"""
roots = {}
for pid, (_pp, name) in snap.items():
n = (name or '').lower()
if n in SANDBOX_EXE:
roots[pid] = n
elif n == 'workbuddy.exe':
cmd, _ = proc_info(pid)
if cmd and '--serve' in cmd:
roots[pid] = n
return roots
def descendants(snap, root):
children = {}
for pid, (ppid, _n) in snap.items():
children.setdefault(ppid, []).append(pid)
seen, stack = set(), [root]
while stack:
cur = stack.pop()
for c in children.get(cur, []):
if c not in seen:
seen.add(c)
stack.append(c)
return seen
def sandbox_tree(snap):
"""所有沙箱根及其后代 PID 的并集"""
tree = set()
for root in sandbox_roots(snap):
tree |= descendants(snap, root) | {root}
return tree
def listening_pids(port):
out = subprocess.run(['netstat', '-ano'], capture_output=True, text=True,
encoding='utf-8', errors='replace').stdout
pids = set()
for line in out.splitlines():
if ':%d ' % port in line and 'LISTENING' in line.upper():
try:
pids.add(int(line.split()[-1]))
except ValueError:
pass
return pids
def trace_up(snap, pid, maxdepth=12):
chain, cur, seen = [], pid, set()
for _ in range(maxdepth):
if not cur or cur in seen:
break
seen.add(cur)
ppid, name = snap.get(cur, (None, None))
if name is None:
cmd, _ = proc_info(cur)
chain.append((cur, '(已退出/取不到)', clean(cmd)))
break
cmd, _ = proc_info(cur)
chain.append((cur, name, clean(cmd)))
if name.lower() in SHELL_ROOTS:
break
cur = ppid
return chain
def report(snap, tree, pid, now):
cmd, ct = proc_info(pid)
name = snap.get(pid, (None, '(快照里没有)'))[1]
age = ''
if ct:
age = '启动于 %s(%.0f 分钟前)' % (ct.strftime('%m-%d %H:%M:%S'),
(now - ct).total_seconds() / 60)
print(' ▶ PID %-7d %-24s %s' % (pid, name, age))
print(' cmd : %s' % (clean(cmd)[:180] or '(取不到)'))
chain = trace_up(snap, pid)
print(' 父链:')
for i, (p, n, c) in enumerate(chain):
if i == 0:
print(' [%d] %s ← 本进程' % (p, n))
else:
print(' %s[%d] %s' % (' ' * i, p, n))
if c:
print(' %s %s' % (' ' * i, c[:160]))
top = (chain[-1][1].lower() if chain else '')
chain_broken = bool(chain) and chain[-1][1].startswith('(已退出')
in_tree = pid in tree
if in_tree:
print(' 判定: ⚠ 属于沙箱进程树 —— 停后台任务 / 会话结束就会被回收')
else:
print(' 判定: ✅ 不在沙箱进程树内 —— 可放心清理后台任务')
if top in SHELL_ROOTS:
print(' 佐证: 父链顶端是 %s' % chain[-1][1])
elif chain_broken:
print(' 注: 父链已断(启动它的临时宿主已退出),这不代表有问题;')
print(' 若需硬证据,跑 --explain-heartbeat 的心跳对照实验。')
print()
def main():
ap = argparse.ArgumentParser(description='Windows 进程树 / 沙箱归属诊断')
ap.add_argument('--port', default='1933,3080',
help='要查的端口,逗号分隔(默认 1933,3080)')
ap.add_argument('--pid', type=int, action='append', default=None,
help='直接查指定 PID(可重复)')
ap.add_argument('--list-sandbox', action='store_true', help='列出沙箱根进程')
ap.add_argument('--explain-heartbeat', action='store_true',
help='打印心跳对照实验步骤')
args = ap.parse_args()
if args.explain_heartbeat:
print('心跳对照实验(验证某进程能否脱离沙箱)')
print('=' * 76)
print('1. 写两个内容相同的 .cmd,都往自己的日志里循环追加时间戳:')
print(' @echo off')
print(' :loop')
print(' echo %date% %time% alive >> "%~dp0_heartbeat.log"')
print(' ping -n 3 127.0.0.1 >nul')
print(' goto loop')
print('2. A 组用 explorer 启动,B 组在沙箱内用 cmd /c start 启动:')
print(' subprocess.Popen([r"C:\\Windows\\explorer.exe", HB])')
print(' subprocess.Popen(["cmd","/c","start","","/min", CT])')
print('3. 本次命令内先读一次基线行数;**下一次工具调用**再读一次。')
print('4. 结论:日志停止增长的那组被回收了。')
print()
print('本机实测结果(2026-09-15,跨两次工具调用读数):')
print(' A explorer 启动 : 5 行 -> 29 行,仍在写 ✅ 存活')
print(' B 沙箱内启动 : 5 行 -> 5 行,时间戳停住 ❌ 被回收')
print('=' * 76)
return
snap = snapshot()
now = datetime.datetime.now()
if args.list_sandbox:
roots = sandbox_roots(snap)
tree = sandbox_tree(snap)
print('=' * 84)
print('沙箱根进程(这些进程的后代会被回收)')
print('=' * 84)
if not roots:
print(' (没找到 sandbox-cli.exe / WorkBuddy.exe --serve)')
for pid, name in sorted(roots.items()):
cmd, ct = proc_info(pid)
n = len(descendants(snap, pid))
print(' PID %-7d %-22s 后代 %d 个' % (pid, name, n))
print(' %s' % clean(cmd)[:160])
print()
print(' 沙箱树总规模: %d 个进程' % len(tree))
print()
tree = sandbox_tree(snap)
if args.pid:
print('=' * 84)
print('指定 PID')
print('=' * 84)
for pid in args.pid:
report(snap, tree, pid, now)
ports = [int(p.strip()) for p in str(args.port).split(',') if p.strip().isdigit()]
for port in ports:
print('=' * 84)
print('端口 %d' % port)
print('=' * 84)
pids = listening_pids(port)
if not pids:
print(' (无人监听)')
print()
continue
for pid in sorted(pids):
report(snap, tree, pid, now)
if __name__ == '__main__':
main()
B.5 ov-stale-lock.py
作用:检测(并可清理)OpenViking 数据目录的陈旧 PID 锁——也就是
DataDirectoryLocked报错的真实原因。判据是双重的:端口无人监听 且 锁里 PID 的进程镜像名不是 OpenViking,两条同时成立才判为陈旧;任一不符就 以退出码 2 退出且不动锁(避免误伤真实持有的锁,造成数据损坏)。用法:
python ov-stale-lock.py(诊断,只读)·--fix(清理,先备份为.openviking.pid.stale-<时间戳>)·--conf <路径>·--port <端口>。退出码:
0无问题(或--fix已清理)·1发现陈旧锁但未清理 ·2疑似有真实实例在跑,不要清锁 ·3配置或环境有问题。依赖:Python 标准库(
socket/subprocess/shutil),不依赖 psutil。 大小:12,958 字节 · 333 行。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""OpenViking 数据目录锁(.openviking.pid)陈旧锁(stale lock)检测与清理。
## 为什么需要这个脚本
OpenViking 用「数据目录下的 `.openviking.pid`」做进程级互斥,防止多个实例同时
写同一份 AGFS / VectorDB 造成静默的数据损坏。逻辑在
`openviking/utils/process_lock.py` 的 `acquire_data_dir_lock()`。
问题出在 `_is_pid_alive(pid)` 的**平台不对称**上:
- Linux 分支:拿到 PID 后还会读 `/proc/<pid>/cmdline`,校验它**是不是真的 OpenViking**。
源码注释里写明了这是为了修 PID 复用导致的误判(upstream issue #1088)。
- **Windows 分支:只判断「这个 PID 有没有进程在」,不做任何身份校验。**
而 Windows 同样会复用 PID。于是在 Windows 上这条路径必然踩坑:
1. 非正常退出(关机 / 任务管理器强杀 / 断电)→ `atexit` 注册的清理回调没跑到 → 锁文件残留;
2. 系统重启后,某个**毫不相干**的进程拿到了同一个 PID;
3. 下次启动 OpenViking → 读到锁文件里的 PID → `_is_pid_alive()` 返回 True(确实有这个进程)
→ 判定「已有实例在跑」→ 直接抛 `DataDirectoryLocked`;
4. 表现:`openviking-server` 退出码 **3**,报的 PID 指向一个跟 OpenViking 毫无关系的进程
(实测踩到过迅雷的 `XLSmartService.exe`),**而 1933 端口其实是空的**。
本脚本用**双重判据**判定陈旧锁,两个判据互相独立,避免误删一个真实持有的锁:
A. 端口判据:配置里的端口无人监听 → 不存在在服务的实例;
B. 身份判据:锁文件里的 PID 对应的进程镜像名不是 OpenViking → 它只是个复用者。
只有 **A ∧ B** 同时成立才判为陈旧锁。任一判据显示「可能有真实实例」就不动锁——
宁可让你人工看一眼,也不要冒数据损坏的风险。
## 用法
<python> ov-stale-lock.py # 诊断(只读,默认)
<python> ov-stale-lock.py --fix # 诊断后清理陈旧锁(先备份再删)
<python> ov-stale-lock.py --conf <路径> # 指定 ov.conf(默认 ~/.openviking/ov.conf)
<python> ov-stale-lock.py --port <端口> # 覆盖端口(默认从 ov.conf 读)
退出码:
0 无锁 / 无问题(--fix 下也可能是"已清理")
1 检测到陈旧锁,尚未清理(未加 --fix),或 --fix 下清理失败
2 疑似有真实实例在跑 —— **不要清锁**,请先确认那个进程
3 配置或环境有问题(读不到 ov.conf、找不到数据目录等)
零依赖:只用标准库(`socket` / `subprocess` / `shutil`),不 import psutil。
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import socket
import subprocess
import sys
import time
LOCK_FILENAME = ".openviking.pid"
# 认定为「OpenViking 自己的进程」的镜像名(小写比对)
OV_IMAGE_NAMES = {
"openviking-server.exe",
"openviking.exe",
"openviking",
"openviking-server",
}
def emit(line: str = "") -> None:
"""往 stdout 写一行。
编码策略(踩过坑,别改):
- 直接跑在 cmd/PowerShell 窗口里(isatty)→ 用控制台自己的编码(中文 Windows
是 cp936),中文正常显示;
- 被重定向到文件 / 管道(非 isatty)→ 一律写 **UTF-8**。
早先版本无条件走 `sys.stdout.write()`,于是重定向后写成 cp936 字节,而
PowerShell 的 `>` 又按 UTF-16LE 落盘,最终日志是「GBK 套 UTF-16」的双重乱码,
根本读不出来。→ 重定向时统一 UTF-8 就干净了。
"""
data = line + "\n"
try:
is_tty = bool(getattr(sys.stdout, "isatty", lambda: False)())
except Exception:
is_tty = False
buf = getattr(sys.stdout, "buffer", None)
if buf is not None:
enc = (getattr(sys.stdout, "encoding", None) or "utf-8") if is_tty else "utf-8"
try:
buf.write(data.encode(enc, "replace"))
buf.flush()
return
except Exception:
pass
try:
sys.stdout.write(data)
sys.stdout.flush()
except (UnicodeEncodeError, UnicodeError):
sys.stdout.write(data.encode("ascii", "replace").decode("ascii"))
sys.stdout.flush()
def default_conf_path() -> str:
return os.path.join(os.path.expanduser("~"), ".openviking", "ov.conf")
def load_conf(conf_path: str) -> dict:
with open(conf_path, encoding="utf-8") as f:
return json.load(f)
def port_is_listening(host: str, port: int, timeout: float = 1.5) -> bool:
"""TCP connect 探测。比 netstat 解析可靠,不依赖外部命令与代码页。"""
for family, addr in (
(socket.AF_INET, (host, port)),
):
try:
s = socket.socket(family, socket.SOCK_STREAM)
s.settimeout(timeout)
try:
if s.connect_ex(addr) == 0:
return True
finally:
s.close()
except OSError:
continue
return False
def pid_image_name(pid: int) -> str | None:
"""取 PID 对应的进程镜像名;进程不存在返回 None。
用 `tasklist`(随 Windows 自带),不引入 psutil。输出形如:
"XLSmartService.exe","6628","Services","0","7,000 K"
"""
if pid <= 0:
return None
try:
out = subprocess.run(
["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
capture_output=True,
text=True,
errors="replace",
timeout=20,
).stdout.strip()
except Exception:
# tasklist 不可用时不臆断,交给调用方按"未知"处理
return None
if not out or "No tasks are running" in out or "没有运行的任务" in out:
return None
first = out.splitlines()[0].strip()
if not first.startswith('"'):
return None
return first.split('"')[1] or None
def read_lock_pid(lock_path: str) -> int:
try:
with open(lock_path, encoding="utf-8", errors="replace") as f:
return int(f.read().strip() or 0)
except (OSError, ValueError):
return 0
def process_cmdline_has_openviking(pid: int) -> bool:
"""兜底身份判据:进程镜像是 python.exe 时,看命令行里有没有 openviking。
正常安装下入口是 `openviking-server.exe`;但有人用
`python -m openviking.server` 之类方式起,也会持有同一个锁。
"""
try:
ps = (
"Get-CimInstance Win32_Process -Filter \"ProcessId=%d\" | "
"Select-Object -ExpandProperty CommandLine" % pid
)
r = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps],
capture_output=True,
text=True,
errors="replace",
timeout=25,
)
return "openviking" in (r.stdout or "").lower()
except Exception:
return False
def main() -> int:
ap = argparse.ArgumentParser(
description="检测(并可清理)OpenViking 数据目录的陈旧 PID 锁",
)
ap.add_argument("--conf", default=None, help="ov.conf 路径(默认 ~/.openviking/ov.conf)")
ap.add_argument("--port", type=int, default=None, help="覆盖端口(默认从 ov.conf 读)")
ap.add_argument("--fix", action="store_true", help="清理陈旧锁(先备份为 .stale-N 再删)")
ap.add_argument(
"--check",
action="store_true",
help="仅诊断、不改动文件(这是默认行为,显式写出来只为意图清楚)",
)
args = ap.parse_args()
conf_path = args.conf or default_conf_path()
emit("=" * 68)
emit("OpenViking 陈旧数据目录锁检测")
emit("=" * 68)
if not os.path.isfile(conf_path):
emit(f"[FATAL] 找不到配置文件: {conf_path}")
emit(" 用 --conf 指定,或确认 OpenViking 是否装在本机。")
return 3
try:
conf = load_conf(conf_path)
except Exception as e:
emit(f"[FATAL] 解析 ov.conf 失败: {e}")
return 3
server = conf.get("server") or {}
storage = conf.get("storage") or {}
host = server.get("host") or "127.0.0.1"
port = args.port or int(server.get("port") or 1933)
workspace = storage.get("workspace")
if not workspace:
emit("[FATAL] ov.conf 里没有 storage.workspace,无法定位数据目录。")
return 3
workspace = os.path.expanduser(str(workspace))
lock_path = os.path.join(workspace, LOCK_FILENAME)
emit(f" 配置 : {conf_path}")
emit(f" 数据目录 : {workspace}")
emit(f" 服务端点 : {host}:{port}")
emit(f" 锁文件 : {lock_path}")
emit("")
if not os.path.isdir(workspace):
emit("[FATAL] 数据目录不存在。OpenViking 还没初始化过?")
return 3
# ---------- 判据 A:端口是否有人监听 ----------
listening = port_is_listening(host, port)
emit(f"[判据 A · 端口] {host}:{port} 监听中 = {listening}")
if not os.path.isfile(lock_path):
emit(f"[判据 B · 锁文件] 不存在")
emit("")
if listening:
emit("[结论] 端口有服务在跑,但没有锁文件 —— 锁可能被外部删过。")
emit(" 服务本身可用;如需重启,直接停掉它再起即可。")
else:
emit("[结论] 干净状态:无锁、端口空闲,可直接启动。")
return 0
# ---------- 判据 B:锁文件里的 PID 到底是谁 ----------
pid = read_lock_pid(lock_path)
mtime = None
try:
mtime = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(os.path.getmtime(lock_path)))
except OSError:
pass
image = pid_image_name(pid) if pid > 0 else None
is_ov_process = bool(image) and image.lower() in OV_IMAGE_NAMES
# 镜像是 python 且命令行带 openviking,也算真实持有者
if image and not is_ov_process and "python" in image.lower():
if process_cmdline_has_openviking(pid):
is_ov_process = True
emit(f"[判据 B · 锁文件] PID={pid} 写入时间={mtime}")
emit(f" 该 PID 当前镜像 = {image or '(无此进程)'}")
emit(f" 是否 OpenViking = {is_ov_process}")
emit("")
# ---------- 判定 ----------
if listening and is_ov_process:
emit("[结论] 正常持有中:有 OpenViking 在监听,且锁的持有者确实是它。")
emit(" 无需任何操作。")
return 2
if listening and not is_ov_process:
emit("[结论] 可疑:端口有服务在监听,但锁文件记的 PID 不是 OpenViking 进程。")
emit(f" 可能这个锁是陈旧的,而当前实例是后来起来的(或改了配置)。")
emit(f" 请先确认 {host}:{port} 上跑的是什么,再决定是否清理。**本脚本不动它。**")
return 2
if not listening and is_ov_process:
emit("[结论] 可疑:端口没人监听,但锁文件记的 PID 确实是 OpenViking 进程。")
emit(f" 它可能是卡在启动/关闭阶段。**出于安全,本脚本不动锁。**")
emit(f" 请先看 PID {pid} 的状态,必要时手动结束它再重试。")
return 2
# not listening and not is_ov_process -> 双重判据都指向陈旧锁
emit("[结论] 陈旧锁(stale lock)—— 这就是 OpenViking 起不来、报")
emit(f" DataDirectoryLocked: Another OpenViking process (PID {pid}) 的原因。")
emit("")
emit(f" 端口 {port} 无人监听 ⇒ 不存在在服务的实例;")
emit(f" PID {pid} 的真实身份是「{image}」⇒ 与 OpenViking 无关,只是复用了这个 PID。")
emit("")
if not args.fix:
emit("[处置] 仅诊断模式,未改动任何文件。确认上面两条后,加 --fix 清理:")
emit(" <python> ov-stale-lock.py --fix")
return 1
# 备份(留排障痕迹)后删除
backup = f"{lock_path}.stale-{time.strftime('%Y%m%d-%H%M%S')}"
try:
shutil.copy2(lock_path, backup)
emit(f"[处置] 已备份旧锁 → {backup}")
except OSError as e:
emit(f"[WARN] 备份失败(继续,旧锁仅含一个 PID,无数据价值): {e}")
try:
os.remove(lock_path)
emit(f"[处置] 已删除陈旧锁 → {lock_path}")
except OSError as e:
emit(f"[FAIL] 删除失败: {e}")
return 1
emit("")
emit("[完成] 现在可以正常启动 OpenViking 了(双击 dsh 图标会自动拉起)。")
return 0
if __name__ == "__main__":
sys.exit(main())
关于这 5 个脚本的一点说明:它们不是"辅助材料",而是本文每一条结论的可执行形态。 文档会过期、会写错(我第一版手册就把 bootstrap 的用法写错了,实测才发现空数组不建库); 脚本每次跑都是最新的事实。
如果你只从这篇文章里带走一样东西,建议是它们,而不是上面的任何一段分析。
更多推荐


所有评论(0)