dsh-recency-guard 0.11.0 → 0.11.1
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/README.md +30 -0
- package/dist/index.js +89 -15
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -65,6 +65,36 @@
|
|
|
65
65
|
|
|
66
66
|
当某个插件把 DSH 拖垮时,本插件会在崩溃的那一刻记录下"谁最可疑",并在**下一次启动时、在崩溃插件的代码被加载之前**把它挡在门外。反复崩溃时,关闭的插件会逐个增加(第 1 次关最近更新的 1 个,仍崩溃则关最近更新的 2 个)。稳定之后,除首要嫌疑外,其余被关闭的配置会自动恢复。
|
|
67
67
|
|
|
68
|
+
## v0.11.1 紧急修复(宿主兼容 + 启动崩溃 · 2026-11-08)
|
|
69
|
+
|
|
70
|
+
**事故背景**:桌面端 DSH 自动升级到 `dsh 0.2.2-rc.1` 后连续崩溃(5 次/3 分钟),
|
|
71
|
+
守护插件被兼容闸门移出 profile、不再保护;重新挂载后又因遗留写锁把宿主拖进
|
|
72
|
+
`fatal load failure`。本版本修复三处,全部是「守护不能拖垮宿主」的底线问题。
|
|
73
|
+
|
|
74
|
+
- **宿主预发布版本兼容(P0)**:`engines.dsh` 与全部 `@deepseek-ai/dsh-*`
|
|
75
|
+
peer 范围改为显式预发布分支
|
|
76
|
+
`>=0.2.1-rc.5 <0.2.2-0 || >=0.2.2-rc.1 <0.2.3-0 || >=0.2.3-rc.1 <0.3.0-0`。
|
|
77
|
+
原因:node-semver 只放行「与范围中某个比较符 **major.minor.patch 元组相同**
|
|
78
|
+
且自身带预发布标签」的预发布版本 —— 旧范围 `>=0.2.1-rc.5 <0.3.0-0` 会
|
|
79
|
+
静默排除 `0.2.2-rc.1`,插件被兼容闸门判为不兼容并从 profile 移除。
|
|
80
|
+
已在 `dsh 0.2.2-rc.1` 干净沙箱(sb-rg-v0111)实测安装 + 启动 + 守护激活通过。
|
|
81
|
+
- **启动绝不抛出(P0)**:`boot()` 失败改为**降级留痕**(步骤记失败 + 警告
|
|
82
|
+
日志),不再 rethrow。此前一个可恢复的写锁超时会把宿主变成
|
|
83
|
+
`dsh: fatal load failure`(退出码 1)—— 观测/恢复设施不能拖垮主流程。
|
|
84
|
+
- **失效写锁自愈(P0)**:新增 `cleanStaleLocks`(Store.init 启动时清理 +
|
|
85
|
+
`updateState` 超时后清理重试一次)。判据:锁内容为空/非正整数 PID(不完整
|
|
86
|
+
记录,atomic-write 永远无法接管)/ PID 对应进程已死,且文件年龄 > 5s
|
|
87
|
+
(避开锁创建与写 PID 之间的零字节窗口,绝不抢活锁)。
|
|
88
|
+
真机事故:崩溃恰好发生在锁创建与写 PID 之间,遗留 0 字节
|
|
89
|
+
`state.json.lock`,此后每次启动都等满超时并致命。
|
|
90
|
+
- **连续崩溃兜底升级(P0)**:`assessConfidence` 新增安全网 —— 累计崩溃
|
|
91
|
+
`crashSeq ≥ 3` 时即使概率计数未达 `confirmRounds` 也强制行动;同时
|
|
92
|
+
`readCrashHistory` 跳过启动时的「无嫌疑 crash-detected」记录。实测该记录
|
|
93
|
+
每轮打断连续计数,导致 crashSeq 涨到 3 仍停在「观察中 1/2 轮」,崩溃循环
|
|
94
|
+
永远无法自愈。
|
|
95
|
+
- 测试:新增 `tests/unit/store-lock.test.ts`(锁自愈 11 断言)与契约层
|
|
96
|
+
预发布范围守卫;14/14 全绿(Core 7 / Host 2 / Client 4 / Contract 1)。
|
|
97
|
+
|
|
68
98
|
## v0.11 新增(数据面安全 + 图谱图例一致性 + 马尔可夫去向可见 + 市场准备)
|
|
69
99
|
|
|
70
100
|
- **动作路由方法守卫(P0 安全修复)**:`/action/archive|reenable|restore|recover`
|
package/dist/index.js
CHANGED
|
@@ -62,7 +62,7 @@ function readHostVersion() {
|
|
|
62
62
|
|
|
63
63
|
// src/store.ts
|
|
64
64
|
import { mkdir, readFile, readdir, stat, unlink } from "node:fs/promises";
|
|
65
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
65
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
66
66
|
import { isAbsolute, join, resolve } from "node:path";
|
|
67
67
|
import { writeFileAtomic, withFileLock } from "@deepseek-ai/dsh-atomic-write";
|
|
68
68
|
var STATE_DIRNAME = ".recency-guard";
|
|
@@ -70,6 +70,55 @@ var JOURNAL_MAX_LINES = 1e3;
|
|
|
70
70
|
function emptyState() {
|
|
71
71
|
return { bootMarker: null, crashSeq: 0, autoDisabled: [], stabilized: false, restored: [] };
|
|
72
72
|
}
|
|
73
|
+
var STALE_LOCK_MIN_AGE_MS = 5e3;
|
|
74
|
+
function processAlive(pid) {
|
|
75
|
+
try {
|
|
76
|
+
process.kill(pid, 0);
|
|
77
|
+
return true;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return error.code === "EPERM";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function cleanStaleLocks(dir, now = Date.now()) {
|
|
83
|
+
const report = { removed: [], kept: [] };
|
|
84
|
+
let entries;
|
|
85
|
+
try {
|
|
86
|
+
entries = readdirSync(dir);
|
|
87
|
+
} catch {
|
|
88
|
+
return report;
|
|
89
|
+
}
|
|
90
|
+
for (const name2 of entries) {
|
|
91
|
+
if (!name2.endsWith(".lock")) continue;
|
|
92
|
+
const path = join(dir, name2);
|
|
93
|
+
try {
|
|
94
|
+
if (now - statSync(path).mtimeMs < STALE_LOCK_MIN_AGE_MS) {
|
|
95
|
+
report.kept.push(name2);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
let text = "";
|
|
99
|
+
try {
|
|
100
|
+
text = readFileSync(path, "utf8").trim();
|
|
101
|
+
} catch {
|
|
102
|
+
report.kept.push(name2);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const pid = Number(text);
|
|
106
|
+
const incomplete = text === "" || !Number.isInteger(pid) || pid <= 0;
|
|
107
|
+
if (!incomplete && processAlive(pid)) {
|
|
108
|
+
report.kept.push(name2);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
rmSync(path, { force: true });
|
|
112
|
+
report.removed.push(name2);
|
|
113
|
+
} catch {
|
|
114
|
+
report.kept.push(name2);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return report;
|
|
118
|
+
}
|
|
119
|
+
function isLockTimeout(error) {
|
|
120
|
+
return error instanceof Error && error.message.includes("timed out waiting for the writer lock");
|
|
121
|
+
}
|
|
73
122
|
var Store = class {
|
|
74
123
|
/** profile 根目录下的守护目录(分区索引与归档都在这里)。 */
|
|
75
124
|
rootDir;
|
|
@@ -105,6 +154,10 @@ var Store = class {
|
|
|
105
154
|
}
|
|
106
155
|
async init() {
|
|
107
156
|
await mkdir(this.backupsDir, { recursive: true });
|
|
157
|
+
try {
|
|
158
|
+
cleanStaleLocks(this.dir);
|
|
159
|
+
} catch {
|
|
160
|
+
}
|
|
108
161
|
}
|
|
109
162
|
/** 本分区的绝对路径(供面板与日志引用)。 */
|
|
110
163
|
get partitionPath() {
|
|
@@ -246,12 +299,23 @@ var Store = class {
|
|
|
246
299
|
}
|
|
247
300
|
/** Serialized read-modify-write across processes. */
|
|
248
301
|
async updateState(mutate) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
302
|
+
const run = () => withFileLock(
|
|
303
|
+
this.statePath,
|
|
304
|
+
async () => {
|
|
305
|
+
const cur = await this.readState();
|
|
306
|
+
const mutated = await mutate(cur);
|
|
307
|
+
await this.writeState(mutated);
|
|
308
|
+
return mutated;
|
|
309
|
+
},
|
|
310
|
+
{ waitMs: 3e3 }
|
|
311
|
+
);
|
|
312
|
+
try {
|
|
313
|
+
return await run();
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (!isLockTimeout(error)) throw error;
|
|
316
|
+
cleanStaleLocks(this.dir);
|
|
317
|
+
return run();
|
|
318
|
+
}
|
|
255
319
|
}
|
|
256
320
|
/**
|
|
257
321
|
* Re-arm the crash marker synchronously.
|
|
@@ -2391,16 +2455,19 @@ function assessConfidence(history, currentSuspects, options = {}) {
|
|
|
2391
2455
|
let streak = 1;
|
|
2392
2456
|
for (let i = history.length - 1; i >= 0; i -= 1) {
|
|
2393
2457
|
const entry = history[i];
|
|
2458
|
+
if (entry.suspects.length === 0) continue;
|
|
2394
2459
|
if ((entry.suspects[0] ?? "") !== head) break;
|
|
2395
2460
|
streak += 1;
|
|
2396
2461
|
}
|
|
2397
|
-
const
|
|
2462
|
+
const hardEscalateAfter = Math.max(1, options.hardEscalateAfter ?? 3);
|
|
2463
|
+
const escalated = options.crashSeq !== void 0 && options.crashSeq >= hardEscalateAfter;
|
|
2464
|
+
const act = streak >= confirmRounds || escalated;
|
|
2398
2465
|
return {
|
|
2399
2466
|
act,
|
|
2400
2467
|
evidence: "probabilistic",
|
|
2401
2468
|
rounds: streak,
|
|
2402
2469
|
required: confirmRounds,
|
|
2403
|
-
label: act ? `\u5934\u53F7\u5ACC\u7591 ${head} \u8FDE\u7EED ${streak} \u8F6E\u590D\u73B0\uFF08\u2265 ${confirmRounds}\uFF09\uFF1A\u8FBE\u5230\u884C\u52A8\u9608\u503C` : `\u5934\u53F7\u5ACC\u7591 ${head} \u590D\u73B0 ${streak}/${confirmRounds} \u8F6E\uFF1A\u89C2\u5BDF\u4E2D\uFF0C\u672C\u8F6E\u53EA\u8BB0\u5F55\u4E0D\u5173\u95ED`
|
|
2470
|
+
label: act ? streak >= confirmRounds ? `\u5934\u53F7\u5ACC\u7591 ${head} \u8FDE\u7EED ${streak} \u8F6E\u590D\u73B0\uFF08\u2265 ${confirmRounds}\uFF09\uFF1A\u8FBE\u5230\u884C\u52A8\u9608\u503C` : `\u8FDE\u7EED ${options.crashSeq} \u6B21\u5D29\u6E83\u4ECD\u672A\u6062\u590D\uFF1A\u515C\u5E95\u5347\u7EA7\u4E3A\u884C\u52A8\uFF08\u5B89\u5168\u7F51\uFF09` : `\u5934\u53F7\u5ACC\u7591 ${head} \u590D\u73B0 ${streak}/${confirmRounds} \u8F6E\uFF1A\u89C2\u5BDF\u4E2D\uFF0C\u672C\u8F6E\u53EA\u8BB0\u5F55\u4E0D\u5173\u95ED`
|
|
2404
2471
|
};
|
|
2405
2472
|
}
|
|
2406
2473
|
function environmentChanged(lastEntry, currentRuntime, currentSuspects) {
|
|
@@ -3599,7 +3666,7 @@ async function buildPartitionSnapshot(input, key) {
|
|
|
3599
3666
|
}
|
|
3600
3667
|
|
|
3601
3668
|
// src/archive.ts
|
|
3602
|
-
import { existsSync as existsSync7, mkdirSync as mkdirSync6, readdirSync, readFileSync as readFileSync7, renameSync as renameSync4, statSync as statSync2, writeFileSync as writeFileSync7 } from "node:fs";
|
|
3669
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync6, readdirSync as readdirSync2, readFileSync as readFileSync7, renameSync as renameSync4, statSync as statSync2, writeFileSync as writeFileSync7 } from "node:fs";
|
|
3603
3670
|
import { join as join7 } from "node:path";
|
|
3604
3671
|
function classify(name2) {
|
|
3605
3672
|
if (name2 === "journal.jsonl") return "journal";
|
|
@@ -3632,7 +3699,7 @@ function archiveAndReset(partitionDir2, workspace, reason, now = /* @__PURE__ */
|
|
|
3632
3699
|
}
|
|
3633
3700
|
if (existsSync7(stateDir)) {
|
|
3634
3701
|
mkdirSync6(archiveDir, { recursive: true });
|
|
3635
|
-
for (const name2 of
|
|
3702
|
+
for (const name2 of readdirSync2(stateDir)) {
|
|
3636
3703
|
if (name2 === "archive") continue;
|
|
3637
3704
|
const source = join7(stateDir, name2);
|
|
3638
3705
|
const target = join7(archiveDir, name2);
|
|
@@ -3677,7 +3744,7 @@ function directoryBytes(dir) {
|
|
|
3677
3744
|
let total = 0;
|
|
3678
3745
|
let names;
|
|
3679
3746
|
try {
|
|
3680
|
-
names =
|
|
3747
|
+
names = readdirSync2(dir);
|
|
3681
3748
|
} catch {
|
|
3682
3749
|
return 0;
|
|
3683
3750
|
}
|
|
@@ -3696,7 +3763,7 @@ function listArchives(partitionDir2) {
|
|
|
3696
3763
|
const archiveRoot = join7(partitionDir2, "archive");
|
|
3697
3764
|
if (!existsSync7(archiveRoot)) return [];
|
|
3698
3765
|
const out = [];
|
|
3699
|
-
for (const name2 of
|
|
3766
|
+
for (const name2 of readdirSync2(archiveRoot)) {
|
|
3700
3767
|
const dir = join7(archiveRoot, name2);
|
|
3701
3768
|
try {
|
|
3702
3769
|
const manifest = JSON.parse(readFileSync7(join7(dir, "manifest.json"), "utf8"));
|
|
@@ -4577,6 +4644,7 @@ var Guard = class {
|
|
|
4577
4644
|
for (const e of events) {
|
|
4578
4645
|
if (e.kind !== "crash-detected") continue;
|
|
4579
4646
|
const suspects = Array.isArray(e.suspects) ? e.suspects.filter((s) => typeof s === "string") : [];
|
|
4647
|
+
if (suspects.length === 0) continue;
|
|
4580
4648
|
out.push({
|
|
4581
4649
|
at: e.t,
|
|
4582
4650
|
exitCode: typeof e.exitCode === "number" ? e.exitCode : -1,
|
|
@@ -4752,7 +4820,10 @@ var Guard = class {
|
|
|
4752
4820
|
const targets = pickSuspects(ranked, n);
|
|
4753
4821
|
const history = this.readCrashHistory();
|
|
4754
4822
|
const envChanged = environmentChanged(history[history.length - 1], safeVersion(), ranked);
|
|
4755
|
-
const verdict = assessConfidence(envChanged ? [] : history, ranked, {
|
|
4823
|
+
const verdict = assessConfidence(envChanged ? [] : history, ranked, {
|
|
4824
|
+
confirmRounds: this.config.confirmRounds,
|
|
4825
|
+
crashSeq: seq
|
|
4826
|
+
});
|
|
4756
4827
|
this.store.appendSync({
|
|
4757
4828
|
kind: "note",
|
|
4758
4829
|
detail: `\u6062\u590D\u5224\u5B9A\uFF1A${verdict.label}`
|
|
@@ -4846,7 +4917,10 @@ ${error?.stack ?? ""}
|
|
|
4846
4917
|
this.endStep("boot", true, "\u542F\u52A8\u6D41\u7A0B\u5B8C\u6210");
|
|
4847
4918
|
} catch (error) {
|
|
4848
4919
|
this.endStep("boot", false, describeError(error));
|
|
4849
|
-
|
|
4920
|
+
try {
|
|
4921
|
+
this.ctx.logger?.warn?.(`[recency-guard] \u5B88\u62A4\u542F\u52A8\u964D\u7EA7\uFF08\u5BBF\u4E3B\u7EE7\u7EED\u542F\u52A8\uFF09\uFF1A${describeError(error)}`);
|
|
4922
|
+
} catch {
|
|
4923
|
+
}
|
|
4850
4924
|
}
|
|
4851
4925
|
}
|
|
4852
4926
|
/** Boot 的实现体(外层负责步骤计时与失败原因留痕)。 */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-recency-guard",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Crash-recovery guard for DeepSeek Harness: records third-party/experimental plugin install & update recency, backs up their config, and after a crash auto-disables the most-recently-updated plugin(s) so the profile boots again, then restores stable config.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -67,10 +67,10 @@
|
|
|
67
67
|
},
|
|
68
68
|
"peerDependencies": {
|
|
69
69
|
"@deepseek-ai/cordis": "~4.0.5",
|
|
70
|
-
"@deepseek-ai/dsh-app-boot": ">=0.2.1-rc.5 <0.3.0-0",
|
|
71
|
-
"@deepseek-ai/dsh-config-editor": ">=0.2.1-rc.5 <0.3.0-0",
|
|
72
|
-
"@deepseek-ai/dsh-plugin-manager": ">=0.2.1-rc.5 <0.3.0-0",
|
|
73
|
-
"@deepseek-ai/dsh-tools": ">=0.2.1-rc.5 <0.3.0-0",
|
|
70
|
+
"@deepseek-ai/dsh-app-boot": ">=0.2.1-rc.5 <0.2.2-0 || >=0.2.2-rc.1 <0.2.3-0 || >=0.2.3-rc.1 <0.3.0-0",
|
|
71
|
+
"@deepseek-ai/dsh-config-editor": ">=0.2.1-rc.5 <0.2.2-0 || >=0.2.2-rc.1 <0.2.3-0 || >=0.2.3-rc.1 <0.3.0-0",
|
|
72
|
+
"@deepseek-ai/dsh-plugin-manager": ">=0.2.1-rc.5 <0.2.2-0 || >=0.2.2-rc.1 <0.2.3-0 || >=0.2.3-rc.1 <0.3.0-0",
|
|
73
|
+
"@deepseek-ai/dsh-tools": ">=0.2.1-rc.5 <0.2.2-0 || >=0.2.2-rc.1 <0.2.3-0 || >=0.2.3-rc.1 <0.3.0-0",
|
|
74
74
|
"@deepseek-ai/schemastery": ">=3.18.0 <4.0.0-0"
|
|
75
75
|
},
|
|
76
76
|
"peerDependenciesMeta": {
|
|
@@ -122,6 +122,6 @@
|
|
|
122
122
|
"typescript": "^5.9.0"
|
|
123
123
|
},
|
|
124
124
|
"engines": {
|
|
125
|
-
"dsh": ">=0.2.1-rc.5 <0.3.0-0"
|
|
125
|
+
"dsh": ">=0.2.1-rc.5 <0.2.2-0 || >=0.2.2-rc.1 <0.2.3-0 || >=0.2.3-rc.1 <0.3.0-0"
|
|
126
126
|
}
|
|
127
127
|
}
|