@moonquake2004/dsh-doctor 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/dsh-doctor.mjs +113 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,37 @@
|
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
+
## [0.6.0] — 2026-09-15
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **P22 profile manifest 带 UTF-8 BOM**(社区 #6758,**error**):DSH 直接 `JSON.parse` 该文件,带 BOM 时抛 `Unexpected token '…' is not valid JSON` 并**启动硬失败**;而 GBK 控制台会把 BOM 三个字节渲染成乱码,用户看不出是编码问题。判据零误报,补的正是"报错不指向病因"缺的那一句,并给出无 BOM 保存的具体做法(PowerShell 5.1 的 `Set-Content -Encoding UTF8` 默认会写 BOM)。
|
|
24
|
+
|
|
25
|
+
### Fixed — 我们自己在同一输入上的错判
|
|
26
|
+
|
|
27
|
+
- 此前直接 `JSON.parse(readFileSync(profile/package.json))`:**带 BOM 时解析失败 → 置 null → P18 报"未找到 profile manifest"**,是**错误结论**;整个 profile 段更会直接报"检查异常"。现在所有 manifest 读取走**带 BOM 感知**的读取:剥离 BOM 后继续工作,`hadBom` 交给 P22 单独报出。**诊断工具不能被它要诊断的那份输入打败**——#6758 让我们发现了自己在同一个输入上的同类问题。
|
|
28
|
+
|
|
29
|
+
## [0.5.1] — 2026-09-15
|
|
30
|
+
|
|
31
|
+
### Fixed — 把"读取间歇性失败"错判成"文件损坏"(社区 #6739)
|
|
32
|
+
|
|
33
|
+
#6739 报告:同一个 25MB 会话每次重读时 `corrupt Zstandard session log: frame at byte N failed validation` 里的 **N 每次不同**,
|
|
34
|
+
在 DSH 外手动重试**立即成功** —— 即那不是文件损坏,而是**偶发的读取/校验失败**(存储、内存或驱动层面的抖动)。
|
|
35
|
+
宿主不重试(0.1.6-alpha.1 的三处 throw 仍无重试),一次失败就整场历史读不出来。
|
|
36
|
+
|
|
37
|
+
**我们此前的做法同样糟**:`catch { problems: ['解压/读取失败'] }` → 直接把这种日志记成**损坏**。
|
|
38
|
+
|
|
39
|
+
现在会话读取带重试,并把三种状态分开:
|
|
40
|
+
| 尝试结果 | 判定 | 输出 |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| 首次即成功 | 正常 | 照旧 |
|
|
43
|
+
| **重试后成功** | **间歇性失败(非损坏)** | 单列一条,说明"文件没坏,是读取路径在抖",并指向存储健康排查 |
|
|
44
|
+
| 三次全失败 | 倾向损坏 | 照旧按损坏处理 |
|
|
45
|
+
|
|
46
|
+
### Changed — ⚠️ 判定变化
|
|
47
|
+
|
|
48
|
+
- 此前被判为"损坏会话"的日志,若重试能读出,**不再计入损坏**,改列为"读取间歇性失败"(不翻退出码,避免偶发性抖动导致结果跳动)。
|
|
49
|
+
|
|
19
50
|
## [0.5.0] — 2026-09-15
|
|
20
51
|
|
|
21
52
|
累积自 `0.4.6` 的全部变更(中间以 0.4.14–0.4.32 的 PATCH 形式先行发布过,此处归并到 MINOR)。
|
package/dsh-doctor.mjs
CHANGED
|
@@ -549,7 +549,14 @@ function checkProfile(name) {
|
|
|
549
549
|
try { dir = resolveProfile(name); } catch (e) { report('profile', 'P0', false, e.message); return; }
|
|
550
550
|
const manifestPath = join(dir, 'package.json');
|
|
551
551
|
if (!existsSync(manifestPath)) { report('profile', 'P0', false, `profile 不存在: ${dir}`); return; }
|
|
552
|
-
|
|
552
|
+
// 用**带 BOM 感知**的读取:带 BOM 的 manifest 会让 DSH 启动硬失败(#6758),
|
|
553
|
+
// 而诊断工具更不能被它要诊断的那份输入打败——剥离 BOM 后继续工作,并由 P22 单独报出病因。
|
|
554
|
+
const mainRead = readJsonReportingBom(manifestPath);
|
|
555
|
+
const manifest = mainRead.data;
|
|
556
|
+
if (manifest === null) {
|
|
557
|
+
report('profile', 'P0', false, `profile manifest 无法解析为 JSON${mainRead.hadBom ? '(且带 UTF-8 BOM —— 见 P22,这正是 DSH 启动硬失败的原因 #6758)' : ''}`);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
553
560
|
const bundles = manifest.dsh?.profile?.bundles ?? [];
|
|
554
561
|
const deps = manifest.dependencies ?? {};
|
|
555
562
|
|
|
@@ -678,7 +685,10 @@ function checkProfile(name) {
|
|
|
678
685
|
// DeepSeek 请求以 REQUEST_EXTENSION 失败(#6667 报告的最小复现)。故这里按"条件性风险"报 warn,不报 fail。
|
|
679
686
|
{
|
|
680
687
|
let profManifest = null;
|
|
681
|
-
|
|
688
|
+
let manifestHadBom = false;
|
|
689
|
+
const profRead = readJsonReportingBom(join(dir, 'package.json'));
|
|
690
|
+
profManifest = profRead.data;
|
|
691
|
+
manifestHadBom = profRead.hadBom;
|
|
682
692
|
if (!profManifest || !(profManifest.dsh && profManifest.dsh.profile)) {
|
|
683
693
|
reportSkip('profile', 'P18', '未找到 profile manifest(无 dsh.profile),跳过 version 检查');
|
|
684
694
|
} else if (typeof profManifest.version === 'string' && profManifest.version.length > 0) {
|
|
@@ -794,6 +804,27 @@ function checkProfile(name) {
|
|
|
794
804
|
}
|
|
795
805
|
}
|
|
796
806
|
|
|
807
|
+
// P22:profile manifest 带 UTF-8 BOM(#6758)—— **硬失败且报错不指向病因**
|
|
808
|
+
// DSH 直接 `JSON.parse` 该文件:带 BOM 时抛 `Unexpected token '...' is not valid JSON` 并**起不来**;
|
|
809
|
+
// 而 GBK 控制台会把 BOM 三个字节渲染成乱码,用户完全看不出是编码问题。
|
|
810
|
+
// 这条判据零误报,且正是"报错不指向病因"要补的那一句:**是 BOM,不是 JSON 语法**。
|
|
811
|
+
{
|
|
812
|
+
const profRead22 = readJsonReportingBom(join(dir, 'package.json'));
|
|
813
|
+
if (!profRead22.exists) {
|
|
814
|
+
reportSkip('profile', 'P22', '无 profile manifest,跳过 BOM 检查');
|
|
815
|
+
} else if (profRead22.hadBom) {
|
|
816
|
+
report('profile', 'P22', false,
|
|
817
|
+
`profile manifest 带 **UTF-8 BOM**(EF BB BF)—— DSH 会直接 \`JSON.parse\` 该文件:`
|
|
818
|
+
+ `BOM 会让它抛 \`SyntaxError: Unexpected token '…' is not valid JSON\`(#6758)并**启动硬失败**;`
|
|
819
|
+
+ `而在 GBK 控制台上那三个字节会显示成乱码,报错里看不出是编码问题`,
|
|
820
|
+
'去掉 BOM(保留 UTF-8 无 BOM):PowerShell 5.1 的 `Set-Content -Encoding UTF8` 默认会写 BOM,改用 '
|
|
821
|
+
+ '`[IO.File]::WriteAllText($p, (Get-Content $p -Raw), (New-Object Text.UTF8Encoding $false))`,'
|
|
822
|
+
+ '或任何「UTF-8(无 BOM)」保存方式;本工具的其余检查已忽略 BOM 继续工作');
|
|
823
|
+
} else {
|
|
824
|
+
report('profile', 'P22', true, 'profile manifest 无 UTF-8 BOM(不会触发 #6758 的启动硬失败)');
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
797
828
|
// P19:插件声明的 host peer 范围 vs 实际提供的 host 版本(社区 #6678 @ciceroyang 提案)
|
|
798
829
|
// 这是"升级后起不来"的常见原因之一:插件声明只支持某段 core 版本,而实际装的核心已在区间外,
|
|
799
830
|
// 安装时却没有任何警告(#6680 的机制:profile 的 pnpm 配置 autoInstallPeers:false 且 host 由
|
|
@@ -1865,7 +1896,8 @@ function scanAllSessions() {
|
|
|
1865
1896
|
// 世代感知:每个会话目录只取权威世代(v3 优先于 v0),与 store 的会话列表对齐
|
|
1866
1897
|
const files = listSessionLogs(root).map((x) => x.f);
|
|
1867
1898
|
if (files.length === 0) { report('session', 'S11', true, '未发现会话日志', undefined); return; }
|
|
1868
|
-
const corrupt = [];
|
|
1899
|
+
const corrupt = [];
|
|
1900
|
+
const unstableReads = []; const oversized = []; const clean = [];
|
|
1869
1901
|
let totalDS = 0; let totalEvents = 0;
|
|
1870
1902
|
for (const f of files) {
|
|
1871
1903
|
const cs = statSync(f).size;
|
|
@@ -1876,8 +1908,16 @@ function scanAllSessions() {
|
|
|
1876
1908
|
for (let i = 0; i <= raw.length - 4; i++) if (raw[i] === magic[0] && raw[i + 1] === magic[1] && raw[i + 2] === magic[2] && raw[i + 3] === magic[3]) frames++;
|
|
1877
1909
|
} catch { corrupt.push({ id: basename(dirname(f)), problems: ['读取失败'] }); continue; }
|
|
1878
1910
|
let text;
|
|
1879
|
-
|
|
1880
|
-
|
|
1911
|
+
{
|
|
1912
|
+
const rd = readSessionText(f);
|
|
1913
|
+
if (rd.state === 'failed') { corrupt.push({ id: basename(dirname(f)), problems: ['解压/读取失败(3 次尝试均失败 → 倾向于文件本身损坏)'] }); continue; }
|
|
1914
|
+
if (rd.state === 'intermittent') {
|
|
1915
|
+
// 重试即成功 → **不是损坏**,是读取路径不稳(社区 #6739 实测:失败帧位置每次不同)
|
|
1916
|
+
unstableReads.push({ id: basename(dirname(f)), attempts: rd.attemptLog.length, sample: rd.attemptLog.find((a) => !a.ok)?.err || '' });
|
|
1917
|
+
continue;
|
|
1918
|
+
}
|
|
1919
|
+
text = rd.text;
|
|
1920
|
+
}
|
|
1881
1921
|
const ds = Buffer.byteLength(text, 'utf8');
|
|
1882
1922
|
totalDS += ds;
|
|
1883
1923
|
// 轻量损坏扫描:seq==index + end-seed 重放 + 未知类型
|
|
@@ -1935,11 +1975,18 @@ function scanAllSessions() {
|
|
|
1935
1975
|
const totalRisk = estHeapMB > heapLimit;
|
|
1936
1976
|
if (quars.length) {
|
|
1937
1977
|
report('session', 'S11', false, `全会话扫描:${corrupt.length} 个损坏会话(#1550:冷打开会拖垮服务器): ${quars.join(' | ')}`, `隔离:把这些会话目录移出 ${join(HOME, 'sessions')}(如 mv 到备份目录)`);
|
|
1938
|
-
} else if (oversized.length || totalRisk) {
|
|
1978
|
+
} else if (oversized.length || totalRisk || unstableReads.length) {
|
|
1939
1979
|
const parts = [];
|
|
1980
|
+
// 间歇性读取失败:**不是文件损坏**(重试即成功、失败帧位置每次不同,见 #6739),
|
|
1981
|
+
// 指向存储/内存/驱动层面的偶发读错误。宿主不重试,一次失败就整场读不出(0.1.6-alpha.1 仍如此);
|
|
1982
|
+
// 我们重试后能读出来,所以这里既如实报告、又明确说清"文件没坏"。
|
|
1983
|
+
if (unstableReads.length) parts.push(`${unstableReads.length} 个会话**读取间歇性失败**(重试即成功 → 非损坏,指向存储/内存/驱动的偶发读错误,社区 #6739): ${unstableReads.slice(0, 3).map((u) => `${u.id}(${u.attempts} 次尝试)`).join(' | ')}`);
|
|
1940
1984
|
if (oversized.length) parts.push(`${oversized.length} 个超大会话: ${oversized.map((o) => `${o.id}(${o.dsMB}MB/${o.events}事件)`).join(' | ')}`);
|
|
1941
1985
|
if (totalRisk) parts.push(`工作区估算物化堆 ~${estHeapMB}MB(估算= max(${totalEvents}事件×600B, ${totalMB}MB×6),跨 ${files.length} 会话累积,#1550 场景;阈值 ${heapLimit}MB,可设 DSH_DOCTOR_HEAP_MB)`);
|
|
1942
|
-
report('session', 'S11', true, `⚠ 全会话扫描:${parts.join(';')}(未损坏,可接受或归档)`,
|
|
1986
|
+
report('session', 'S11', true, `⚠ 全会话扫描:${parts.join(';')}(未损坏,可接受或归档)`,
|
|
1987
|
+
unstableReads.length
|
|
1988
|
+
? '间歇性读取失败不是日志问题:先重试读取;持续出现则排查存储健康(SMART)、内存与磁盘驱动(#6739 的证据是失败帧位置每次不同)'
|
|
1989
|
+
: '冷启动会明显变慢;必要时压缩/归档历史会话');
|
|
1943
1990
|
} else {
|
|
1944
1991
|
report('session', 'S11', true, `全会话扫描:${clean.length} 个会话均健康(损坏 0 / 超大 0 / 估算物化堆 ${estHeapMB}MB)`, undefined);
|
|
1945
1992
|
}
|
|
@@ -1963,7 +2010,8 @@ catalogSeverity.set('P16', 'warn');
|
|
|
1963
2010
|
catalogSeverity.set('P17', 'warn');
|
|
1964
2011
|
catalogSeverity.set('P18', 'warn');
|
|
1965
2012
|
catalogSeverity.set('P19', 'warn');
|
|
1966
|
-
catalogSeverity.set('P20', 'warn');
|
|
2013
|
+
catalogSeverity.set('P20', 'warn');
|
|
2014
|
+
catalogSeverity.set('P22', 'error'); // #6758:BOM 会让启动硬失败,零误报 → 按 error // #6693:client 格式问题在浏览器才炸,服务端零痕迹——高价值提示但不阻断 CI
|
|
1967
2015
|
// P21 不设 warn:它是在 apply() 内**同步抛出**、直接中断整棵装载链的致命类(#6693 实测 195+55 次),
|
|
1968
2016
|
// 且我们两轮去误报(注释/字符串)后在真实 profile 的 8 个 host 入口 + 8 个 client 产物上零误报,故按 error 处理。 // #6678:声明不匹配是风险信号而非确定失败,提示但不阻断 // #6667:条件性风险(需游离本地模块才触发),提示但不翻退出码
|
|
1969
2017
|
|
|
@@ -2485,6 +2533,63 @@ function classifyImportError(msg) {
|
|
|
2485
2533
|
}
|
|
2486
2534
|
|
|
2487
2535
|
|
|
2536
|
+
|
|
2537
|
+
/**
|
|
2538
|
+
* 会话日志读取:**带重试**,并区分"文件损坏"与"读取路径不稳"。
|
|
2539
|
+
*
|
|
2540
|
+
* 社区 #6739 的证据:同一个 25MB 会话,每次重读时 `corrupt Zstandard session log: frame at byte N
|
|
2541
|
+
* failed validation` 里的 **N 每次不同**,而在 DSH 外对同一文件手动重试**立即成功**。
|
|
2542
|
+
* 也就是说——那不是文件损坏,而是**偶发的读取/校验失败**(存储、内存或驱动层面的抖动)。
|
|
2543
|
+
* 宿主不重试,一次失败就整场历史读不出来(0.1.6-alpha.1 仍如此:persistence 的三处 throw 无重试);
|
|
2544
|
+
* 我们此前的做法同样糟糕:直接把这种日志记成"损坏"。
|
|
2545
|
+
*
|
|
2546
|
+
* 现在:失败重试若干次;只要有一次成功,就**不判为损坏**,而是单独报"读取间歇性失败"——
|
|
2547
|
+
* 这是宿主不会给出的区分,也是用户真正需要的那一句:"你的日志没坏,是你的读取路径在抖"。
|
|
2548
|
+
*/
|
|
2549
|
+
function readSessionText(file, attempts = 3) {
|
|
2550
|
+
const attemptLog = [];
|
|
2551
|
+
for (let i = 0; i < attempts; i++) {
|
|
2552
|
+
try {
|
|
2553
|
+
const text = file.endsWith('.zstd')
|
|
2554
|
+
? execFileSync('zstd', ['-dc', file], { maxBuffer: 512 * 1024 * 1024 }).toString('utf8')
|
|
2555
|
+
: readFileSync(file, 'utf8');
|
|
2556
|
+
attemptLog.push({ ok: true });
|
|
2557
|
+
return { text, attemptLog, state: attemptLog.length === 1 ? 'ok' : 'intermittent' };
|
|
2558
|
+
} catch (e) {
|
|
2559
|
+
attemptLog.push({ ok: false, err: String(e.stderr || e.message || '').split('\n').find((l) => /failed validation|error/i.test(l)) || String(e.message || '') });
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
return { text: null, attemptLog, state: 'failed' };
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
/** 由若干次尝试的结果判定状态(纯函数,便于测试)。 */
|
|
2566
|
+
function classifyReadAttempts(attemptLog) {
|
|
2567
|
+
const ok = attemptLog.filter((a) => a.ok).length;
|
|
2568
|
+
if (ok === attemptLog.length) return 'ok';
|
|
2569
|
+
if (ok === 0) return 'failed';
|
|
2570
|
+
return 'intermittent';
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
|
|
2574
|
+
/**
|
|
2575
|
+
* 读 JSON 并**报告是否带 UTF-8 BOM**。
|
|
2576
|
+
*
|
|
2577
|
+
* 社区 #6758:profile 清单只要带 BOM(PowerShell 5.1 的 `Set-Content -Encoding UTF8` 默认会写),
|
|
2578
|
+
* `JSON.parse` 就抛 `Unexpected token '...'`,DSH **硬失败起不来**;而 GBK 控制台会把 BOM 三个字节
|
|
2579
|
+
* 渲染成乱码,报错里完全看不出是编码问题——"报错不指向病因"。
|
|
2580
|
+
*
|
|
2581
|
+
* 我们自己也踩同一个坑:此前直接 `JSON.parse(readFileSync(...))` 失败后置 null,于是 P18 会报
|
|
2582
|
+
* "未找到 profile manifest"——**错误结论**。诊断工具在它要诊断的输入上给出错判,正是最该避免的。
|
|
2583
|
+
* 所以:**剥离 BOM 后再解析**(让其余检查继续工作),并把 hadBom 单独报出来(P22)。
|
|
2584
|
+
*/
|
|
2585
|
+
function readJsonReportingBom(file) {
|
|
2586
|
+
let raw;
|
|
2587
|
+
try { raw = readFileSync(file); } catch { return { data: null, hadBom: false, exists: false }; }
|
|
2588
|
+
const hadBom = raw.length >= 3 && raw[0] === 0xEF && raw[1] === 0xBB && raw[2] === 0xBF;
|
|
2589
|
+
const text = hadBom ? raw.subarray(3).toString('utf8') : raw.toString('utf8');
|
|
2590
|
+
try { return { data: JSON.parse(text), hadBom, exists: true }; } catch { return { data: null, hadBom, exists: true }; }
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2488
2593
|
/* ---- 预检增强(2026-09):快照对比 + 安全安装 ---- */
|
|
2489
2594
|
|
|
2490
2595
|
const SNAPSHOT_FILE = '.dsh-doctor-snapshot.json';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moonquake2004/dsh-doctor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Offline diagnostic for DeepSeek Harness — 28 built-in + 5 catalog checks across env/profile/session (Layer A checks-as-data), self-update (Layer B), and a semi-automatic LLM observer (Layer C, --observe); Doctor panel in web UI settings.",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"files": [
|