DeepSeek Harness 本地部署与第三方插件排障实录:从 fetch failed 到 preset not found

本文是一次完整真实的排障复盘:在 Windows 上从零跑通 DeepSeek Harness(dsh), 再装上一个第三方 client UI 插件,然后把踩到的每一个坑连根因带修法全部拆开。 文中所有报错原文、版本号、字节数、耗时、进程树结论均来自真机实测,没有推测。 配套交付:一份排障手册 + 5 个只读排障脚本(见第八节)。

📌 本文持续更新。最近一次追加的是故障 C(第六节):第二天启动时 DataDirectoryLocked 把服务挡在门外,而它点名的那个"占用进程"跟 OpenViking 毫无关系。


〇、先给结论:四句话

如果你正准备装 dsh 或它的第三方插件,这四条能省掉大部分试错:

  1. 插件报的错,多半不是你的配置错。 我遇到的两个跟插件有关的故障——知识库页 fetch failed、新建会话 preset not found——根因都在插件的发布包: 一个把硬依赖写成了"可选",另一个干脆漏打了一个目录。你的配置从头到尾没写错。

  2. 502 是插件的兜底错误码,不代表 dsh 挂了。 看源码就知道,整个路由 handler 的 catch 分支统一 return json(res, 502, ...),所有异常都是 502。 而且它的接口分三类行为,第三类会静默返回空——这是最容易误判的一条。

  3. 最隐蔽的坑不在安装,在"谁启动了服务"。 让 AI 助手在会话里帮你把服务跑起来, 服务会挂在会话的进程沙箱上:助手说"已就绪、路由全 200"是真的,过一会儿连不上也是真的。 想清理助手留下的后台任务又怕服务挂?根因在这里,修法在第七节。

  4. 报错信息里的"事实"要自己验一遍。 DataDirectoryLocked 言之凿凿地说"另一个 OpenViking 进程正占用数据目录",但那个 PID 其实是迅雷,端口还是空的—— 报错是在陈述一个错误的前提。尤其是当它请你做一个有风险的动作时(比如"删掉这个锁"), 先把里面的具体值核一遍。详见第六节。


一、环境与版本(可对照)

先把坐标系定死,本文所有结论都基于这套环境:

组件实测版本说明
操作系统Windows 11中文系统,代码页 936
DeepSeek Harness0.1.5-rc.1(latest 标签)开发者预览版,官方明说会有破坏性变更
dsh web UI127.0.0.1:3080必须带 ?token= 完整 URL 访问
OpenViking0.4.20知识库服务,127.0.0.1:1933
llama-cpp-python0.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(...),无 catch502 {"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 6628XLSmartService.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 上没修。 于是这条路径必然踩:

  1. 非正常退出(关机 / 任务管理器强杀 / 断电)→ atexit 注册的清理回调没跑到 → 锁文件残留;
  2. 系统重启后,某个毫不相干的进程(这次是迅雷)拿到了同一个 PID;
  3. 下次启动读到锁里的 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 webexplorer.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 硬证据:心跳对照实验

判据给结论,心跳实验给证据——同一个脚本两种启动方式,跨两次工具调用看日志是否还在增长:

组启动方式结果(实测)
Aexplorer.exe <脚本>5 行 → 29 行,仍在写 ✅ 存活
B会话内 cmd /c start5 行 → 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 上 python urllib 会读注册表里的系统代理(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(它要 import dsh 自己的 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 没跑(第三类"静默失败型")

十、复盘:可以带走的五条经验

  1. 先定位"是谁的错",再动手改环境。 这次的三个故障,责任方分别是: 插件发布包漏打目录(故障 B)、插件把硬依赖写成"可选"(故障 A)、 上游 OpenViking 的 Windows 分支漏了校验(故障 C)。 三个都不是"我的配置写错了"。读源码定位责任方的成本,比在自己配置里乱试低一个数量级—— 而且能避免你去"修"一个本来就正确的地方。

  2. 同一个错误码背后可能有三类行为。 502 统一兜底、/resources 静默吞错—— 如果只按"错误码"分类,就会把"页面能打开但列表为空"误判成"还没上传文档"。 按接口的源码行为分类,而不是按 HTTP 状态码分类。

  3. "能连上端口"不等于"服务独立"。 判断服务会不会被回收,必须看进程树归属, 不能靠"我现在能访问"。而且 IsProcessInJob 这类 API 在真机上可能给出假阳性, 最终要用心跳对照实验拿硬证据。

  4. 把排查结论固化成脚本,而不是文档。 文档会过期、会写错(我第一版手册就把 bootstrap 的用法写错了,实测才发现空数组不建库)。脚本每次跑都是最新的事实, 而且可以直接交给别人。

  5. 报错信息的措辞是"结论",不是"事实"。 DataDirectoryLocked 说"另一个 OpenViking 进程正在使用数据目录",听起来无懈可击——但我一验身份,那个 PID 是迅雷,端口还是空的。 报错里给出的任何具体值(PID、路径、行号)都要自己验一遍,尤其是当它要求你做一个 有风险的动作用户(这里是"删掉看起来被别人持有的锁")。

附一条方法论:我一度相信"cmd 的 rem 行里写 <pid> 会被当成重定向"(网上常见说法), 差点据此去改生产启动器。花 10 秒写了个最小 .cmd 实测,两种写法的返回码、 stdout、stderr 完全一致——该说法在这种形态下不成立。 凡是"我以为是坑"的假设,先写最小用例跑一遍,再动生产代码。


附录 A:路径与端口速查

项默认位置
dsh webhttp://127.0.0.1:3080(必须带 ?token=...)
OpenVikinghttp://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.1chengce-doctor.pyPython 标准库一键体检,直接给结论
B.2verify_presets.mjsNodepreset 权威判定
B.3make_chengce_presets.pyPython 标准库幂等补建 preset
B.4win-proc-tree.py零依赖(ctypes)沙箱归属诊断
B.5ov-stale-lock.pyPython 标准库陈旧数据目录锁检测 / 清理

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 的用法写错了,实测才发现空数组不建库); 脚本每次跑都是最新的事实。

如果你只从这篇文章里带走一样东西,建议是它们,而不是上面的任何一段分析。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐