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.
Files changed (3) hide show
  1. package/README.md +30 -0
  2. package/dist/index.js +89 -15
  3. 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
- return withFileLock(this.statePath, async () => {
250
- const cur = await this.readState();
251
- const mutated = await mutate(cur);
252
- await this.writeState(mutated);
253
- return mutated;
254
- }, { waitMs: 3e3 });
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 act = streak >= confirmRounds;
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 readdirSync(stateDir)) {
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 = readdirSync(dir);
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 readdirSync(archiveRoot)) {
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, { confirmRounds: this.config.confirmRounds });
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
- throw error;
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.0",
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
  }