@dsh-plus/lifeboat 0.1.2 → 0.1.4
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/lib/client.js +27 -29
- package/lib/index.d.ts +0 -3
- package/lib/index.js +203 -182
- package/package.json +5 -5
- package/src/config.ts +15 -6
- package/src/fallback-llm.ts +52 -20
- package/src/index.ts +20 -7
- package/src/notify.ts +12 -6
- package/src/quarantine.ts +29 -7
package/lib/client.js
CHANGED
|
@@ -3,37 +3,35 @@ window.__ModuleLoader__.load({
|
|
|
3
3
|
factory: (require) => {
|
|
4
4
|
var module = { exports: {} };
|
|
5
5
|
var exports = module.exports;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
function apply(ctx) {
|
|
16
|
-
try {
|
|
17
|
-
const reported = /* @__PURE__ */ new Set();
|
|
18
|
-
ctx.root.on("internal/status", (fiber) => {
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
//#region src/client/client.ts
|
|
8
|
+
const name = "dsh-plus-lifeboat";
|
|
9
|
+
/** Runtime mirror: FiberState 是 cordis 跨包 const enum(FAILED = 3,cordis 4.0.1)。 */
|
|
10
|
+
const FIBER_FAILED = 3;
|
|
11
|
+
/** 永不回报的条目:救生艇自身与聚合 bundle。 */
|
|
12
|
+
const EXCLUDED = /* @__PURE__ */ new Set(["dsh-plus-lifeboat", "dsh-plus-bundle-main"]);
|
|
13
|
+
const GUARD_RE = /^dsh-plus-[a-z0-9-]+$/;
|
|
14
|
+
function apply(ctx) {
|
|
19
15
|
try {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
16
|
+
const reported = /* @__PURE__ */ new Set();
|
|
17
|
+
ctx.root.on("internal/status", (fiber) => {
|
|
18
|
+
try {
|
|
19
|
+
if (fiber.state !== FIBER_FAILED) return;
|
|
20
|
+
const plugin = fiber.name;
|
|
21
|
+
if (!GUARD_RE.test(plugin) || EXCLUDED.has(plugin) || reported.has(plugin)) return;
|
|
22
|
+
reported.add(plugin);
|
|
23
|
+
fetch("/dsh-plus/lifeboat/quarantine", {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { "content-type": "application/json" },
|
|
26
|
+
body: JSON.stringify({ name: plugin })
|
|
27
|
+
}).catch(() => {});
|
|
28
|
+
} catch {}
|
|
29
|
+
});
|
|
29
30
|
} catch {}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
//#endregion
|
|
35
|
-
exports.apply = apply;
|
|
36
|
-
exports.name = name;
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
exports.apply = apply;
|
|
34
|
+
exports.name = name;
|
|
37
35
|
return module.exports;
|
|
38
36
|
}
|
|
39
37
|
});
|
package/lib/index.d.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import z from "@deepseek-ai/schemastery";
|
|
2
|
-
import "@deepseek-ai/dsh-settings";
|
|
3
2
|
import { Context } from "@deepseek-ai/cordis";
|
|
4
|
-
|
|
5
3
|
//#region src/config.d.ts
|
|
6
|
-
|
|
7
4
|
/** 行级配置:全部有默认值,正常部署零配置。 */
|
|
8
5
|
declare const Config: z<Schemastery.ObjectS<{
|
|
9
6
|
enabled: z<boolean, boolean>;
|
package/lib/index.js
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
2
|
-
import z from "@deepseek-ai/schemastery";
|
|
3
2
|
import { SettingsConflictError, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
4
|
import { copyFile, readFile, rename, writeFile } from "node:fs/promises";
|
|
5
5
|
import { parseDocument } from "yaml";
|
|
6
|
-
|
|
7
|
-
//#region src/ns.ts
|
|
8
|
-
/**
|
|
9
|
-
* settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
|
|
10
|
-
* @module lifeboat/ns
|
|
11
|
-
*/
|
|
12
|
-
const SETTINGS_NS$1 = "dsh-plus-lifeboat";
|
|
13
|
-
|
|
14
6
|
//#endregion
|
|
15
7
|
//#region src/config.ts
|
|
8
|
+
/**
|
|
9
|
+
* 配置与 journal 单一事实源。
|
|
10
|
+
* - 行级 Config(cordis 组合层):救生艇自身行为开关,dev/prod patch 层可覆盖。
|
|
11
|
+
* - settings 命名空间 dsh-plus-lifeboat:运行期 journal 与 LLM 应急翻译状态,
|
|
12
|
+
* 经 dsh-settings-file 持久化到 $DSH_HOME/settings.yaml,重启后可据此还原。
|
|
13
|
+
* schema 刻意宽松:lifeboat 是最后防线,自身命名空间的数据问题绝不能让它起不来。
|
|
14
|
+
* @module lifeboat/config
|
|
15
|
+
*/
|
|
16
16
|
/** settings 命名空间(品牌化后供 settings 服务注册/读写)。 */
|
|
17
|
-
const SETTINGS_NS = settingsNamespace(
|
|
17
|
+
const SETTINGS_NS = settingsNamespace("dsh-plus-lifeboat");
|
|
18
18
|
/** 行级配置:全部有默认值,正常部署零配置。 */
|
|
19
19
|
const Config = z.object({
|
|
20
20
|
enabled: z.boolean().description("故障隔离总开关").default(true),
|
|
@@ -42,176 +42,27 @@ const JournalSchema = z.object({
|
|
|
42
42
|
journal: z.array(JournalEntry).description("救生艇操作日志(最新在尾,封顶 50 条)").default([]),
|
|
43
43
|
llmFallback: z.union([FallbackState, z.const(null)]).description("LLM 应急翻译状态;null 表示未处于降级").default(null)
|
|
44
44
|
});
|
|
45
|
-
|
|
46
45
|
//#endregion
|
|
47
|
-
//#region src/
|
|
48
|
-
/**
|
|
49
|
-
* 装配告警出口。返回同步派单函数:内部异步发送,失败只记日志。
|
|
50
|
-
* notify-email 出现时升级为邮件;全程 journal 兜底可溯。
|
|
51
|
-
*/
|
|
52
|
-
function installAlerter(ctx, journal) {
|
|
53
|
-
const logger = ctx.logger("lifeboat");
|
|
54
|
-
let mailer;
|
|
55
|
-
ctx.inject(["notifyEmail"], (mailCtx) => {
|
|
56
|
-
mailer = mailCtx.notifyEmail;
|
|
57
|
-
return () => {
|
|
58
|
-
mailer = void 0;
|
|
59
|
-
};
|
|
60
|
-
});
|
|
61
|
-
return (subject, text) => {
|
|
62
|
-
journal("alert", `${subject} | ${text}`);
|
|
63
|
-
const active = mailer;
|
|
64
|
-
if (!active?.sendNotice) {
|
|
65
|
-
logger.warn(`${subject} —— ${text}`);
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
active.sendNotice({
|
|
69
|
-
subject,
|
|
70
|
-
text
|
|
71
|
-
}).then((result) => {
|
|
72
|
-
if (!result.ok) logger.warn(`邮件告警投递失败: ${result.detail}`);
|
|
73
|
-
}, (error) => {
|
|
74
|
-
logger.warn(`邮件告警异常: ${error instanceof Error ? error.message : String(error)}`);
|
|
75
|
-
});
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
//#endregion
|
|
80
|
-
//#region src/patch-file.ts
|
|
81
|
-
/** 条目(含 insert 子列表)中是否已有针对 targetId 的 disabled 覆盖。 */
|
|
82
|
-
function hasDisable(entries, targetId) {
|
|
83
|
-
for (const entry of entries) {
|
|
84
|
-
if (entry.id === targetId && entry.disabled === true) return true;
|
|
85
|
-
if (Array.isArray(entry.insert) && hasDisable(entry.insert, targetId)) return true;
|
|
86
|
-
}
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
/**
|
|
90
|
-
* 向 patch 文件追加一条禁用覆盖;返回是否实际写入(已存在禁用则跳过)。
|
|
91
|
-
* @throws 文件不可读/顶层不是数组/落盘失败——调用方负责告警。
|
|
92
|
-
*/
|
|
93
|
-
async function appendDisable(patchFile, targetId) {
|
|
94
|
-
const doc = parseDocument(await readFile(patchFile, "utf-8"));
|
|
95
|
-
const root = doc.toJS();
|
|
96
|
-
if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`);
|
|
97
|
-
if (hasDisable(root, targetId)) return false;
|
|
98
|
-
await copyFile(patchFile, `${patchFile}.lifeboat.bak`);
|
|
99
|
-
doc.add({
|
|
100
|
-
id: targetId,
|
|
101
|
-
disabled: true
|
|
102
|
-
});
|
|
103
|
-
const tmp = `${patchFile}.lifeboat.tmp`;
|
|
104
|
-
await writeFile(tmp, doc.toString(), "utf-8");
|
|
105
|
-
await rename(tmp, patchFile);
|
|
106
|
-
return true;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
//#endregion
|
|
110
|
-
//#region src/quarantine.ts
|
|
111
|
-
/** Runtime mirror: FiberState 是 cordis 跨包 const enum(官方 dsh-host-plugin-inventory 同款做法)。 */
|
|
112
|
-
const FIBER_FAILED = 3;
|
|
113
|
-
/** 永不隔离的条目:救生艇自身与聚合 bundle(禁了它们等于关掉整个防线/全家)。 */
|
|
114
|
-
const EXCLUDED = new Set(["dsh-plus-lifeboat", "dsh-plus-bundle-main"]);
|
|
115
|
-
const GUARD_RE = /^dsh-plus-[a-z0-9-]+$/;
|
|
116
|
-
/** 名字是否属于可隔离的 dsh-plus 兄弟插件(entry id 与插件 name 同字面量是本仓库约定)。 */
|
|
117
|
-
function isGuardedPlugin(name$1) {
|
|
118
|
-
return typeof name$1 === "string" && GUARD_RE.test(name$1) && !EXCLUDED.has(name$1);
|
|
119
|
-
}
|
|
120
|
-
/** 装配隔离执行器(纯逻辑,可测)。 */
|
|
121
|
-
function createQuarantine(ctx, deps) {
|
|
122
|
-
const logger = ctx.logger("lifeboat");
|
|
123
|
-
const lastAlertAt = /* @__PURE__ */ new Map();
|
|
124
|
-
const inFlight = /* @__PURE__ */ new Set();
|
|
125
|
-
async function quarantine(name$1, origin) {
|
|
126
|
-
if (!isGuardedPlugin(name$1)) return;
|
|
127
|
-
if (inFlight.has(name$1)) return;
|
|
128
|
-
inFlight.add(name$1);
|
|
129
|
-
try {
|
|
130
|
-
const written = await appendDisable(deps.patchFile, name$1);
|
|
131
|
-
deps.journal("quarantine", `${name$1}(来源 ${origin},${written ? "已写入禁用" : "已存在禁用"})`);
|
|
132
|
-
const now = Date.now();
|
|
133
|
-
if (now - (lastAlertAt.get(name$1) ?? 0) >= deps.alertCooldownMs) {
|
|
134
|
-
lastAlertAt.set(name$1, now);
|
|
135
|
-
deps.alert(`[DSH] 插件 ${name$1} 加载失败,已自动隔离`, `检测到插件 ${name$1} 的 fiber 进入 FAILED(来源:${origin})。\n已向 ${deps.patchFile} 写入 disabled 覆盖,重启/刷新后该插件不再加载,其余插件不受影响。\n修复后删除该条覆盖即可恢复。`);
|
|
136
|
-
}
|
|
137
|
-
} catch (error) {
|
|
138
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
139
|
-
logger.warn(`隔离 ${name$1} 失败: ${message}`);
|
|
140
|
-
deps.journal("quarantine-error", `${name$1}: ${message}`);
|
|
141
|
-
deps.alert(`[DSH] 插件 ${name$1} 加载失败,自动隔离未果`, `隔离写入失败:${message}\n请手动在 patch 层禁用该插件。`);
|
|
142
|
-
} finally {
|
|
143
|
-
inFlight.delete(name$1);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return { quarantine };
|
|
147
|
-
}
|
|
46
|
+
//#region src/fallback-llm.ts
|
|
148
47
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
48
|
+
* LLM 应急翻译:默认模型路由的 provider 无已注册 adapter 时(典型场景:llm-pi
|
|
49
|
+
* 被隔离/加载失败),把 settings.yaml 里 dsh-plus-llm-pi 的 provider 配置翻译为
|
|
50
|
+
* 官方 llm-pi-ai 命名空间的原生格式(键加 -fb 后缀避开 DUPLICATE_ADAPTER 冲突),
|
|
51
|
+
* 默认模型指到翻译路由;源 provider 恢复健康后按 journal 自动还原。
|
|
52
|
+
*
|
|
53
|
+
* 为什么读原始 settings.yaml 而不是 settings 服务:插件被禁用后其命名空间随之
|
|
54
|
+
* 注销,服务层读不到——但数据仍在文件里(只读,不修改)。
|
|
55
|
+
* 降级语义:翻译只覆盖官方物化的字段子集,dsh-plus 扩展的全量 compat 丢弃;
|
|
56
|
+
* 协议推断是启发式(显式 api > 路由名 > 模型继承源 > openai-completions 兜底)。
|
|
57
|
+
* @module lifeboat/fallback-llm
|
|
151
58
|
*/
|
|
152
|
-
function installHostWatch(ctx, q) {
|
|
153
|
-
ctx.root.on("internal/status", (fiber) => {
|
|
154
|
-
if (fiber.state !== FIBER_FAILED) return;
|
|
155
|
-
if (!isGuardedPlugin(fiber.name)) return;
|
|
156
|
-
q.quarantine(fiber.name, "host");
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
//#endregion
|
|
161
|
-
//#region src/quarantine-api.ts
|
|
162
|
-
const ROUTE = "/dsh-plus/lifeboat/quarantine";
|
|
163
|
-
function sendJson(res, status, body) {
|
|
164
|
-
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
165
|
-
res.end(JSON.stringify(body));
|
|
166
|
-
}
|
|
167
|
-
function readBody(req) {
|
|
168
|
-
return new Promise((resolve, reject) => {
|
|
169
|
-
const chunks = [];
|
|
170
|
-
req.on("data", (chunk) => chunks.push(chunk));
|
|
171
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
172
|
-
req.on("error", reject);
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
/** 注册哨兵回报路由(webServer 缺失时由调用方保证不调用)。 */
|
|
176
|
-
function registerQuarantineApi(ctx, q) {
|
|
177
|
-
const logger = ctx.logger("lifeboat");
|
|
178
|
-
ctx.webServer.register({
|
|
179
|
-
kind: "prefix",
|
|
180
|
-
path: ROUTE,
|
|
181
|
-
handler: async (req, res) => {
|
|
182
|
-
try {
|
|
183
|
-
if (req.method !== "POST") {
|
|
184
|
-
sendJson(res, 405, { error: "POST only" });
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
const body = JSON.parse(await readBody(req));
|
|
188
|
-
if (typeof body.name !== "string") {
|
|
189
|
-
sendJson(res, 400, { error: "missing name" });
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
q.quarantine(body.name, "client");
|
|
193
|
-
sendJson(res, 200, { ok: true });
|
|
194
|
-
} catch (error) {
|
|
195
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
196
|
-
logger.warn(`quarantine api failed: ${message}`);
|
|
197
|
-
if (!res.headersSent) sendJson(res, 400, { error: message });
|
|
198
|
-
else res.end();
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
//#endregion
|
|
205
|
-
//#region src/fallback-llm.ts
|
|
206
59
|
const NS_PI_AI = settingsNamespace("llm-pi-ai");
|
|
207
60
|
const NS_AGENT_DEFAULT = settingsNamespace("agent-default-model");
|
|
208
61
|
const RAW_NS_LLM_PI = "dsh-plus-llm-pi";
|
|
209
|
-
/** 翻译路由键后缀:与源路由错开,避免 llm-pi 恢复瞬间撞 DUPLICATE_ADAPTER。 */
|
|
210
|
-
const FALLBACK_SUFFIX = "-fb";
|
|
211
62
|
/** 官方配置路径物化的 compat 子集(其余字段降级期丢弃)。 */
|
|
212
63
|
const COMPAT_WHITELIST = ["thinkingFormat", "supportsReasoningEffort"];
|
|
213
64
|
/** 官方 reasoning 词表(ModelThinkingLevel 的保守子集),不在其中的值丢弃。 */
|
|
214
|
-
const REASONING_LEVELS = new Set([
|
|
65
|
+
const REASONING_LEVELS = /* @__PURE__ */ new Set([
|
|
215
66
|
"off",
|
|
216
67
|
"minimal",
|
|
217
68
|
"low",
|
|
@@ -278,12 +129,13 @@ function translateProvider(routeKey, raw) {
|
|
|
278
129
|
/** 整表翻译:键加 -fb 后缀(纯函数)。 */
|
|
279
130
|
function translateProviders(providers) {
|
|
280
131
|
const out = {};
|
|
281
|
-
for (const [key, raw] of Object.entries(providers)) out[`${key}
|
|
132
|
+
for (const [key, raw] of Object.entries(providers)) out[`${key}-fb`] = translateProvider(key, raw);
|
|
282
133
|
return out;
|
|
283
134
|
}
|
|
284
135
|
/** 读 settings.yaml 原始文档的指定段落(只读;文件/段落缺失返回 undefined)。 */
|
|
285
136
|
async function readRawSection(settingsFile, ns) {
|
|
286
|
-
const
|
|
137
|
+
const text = await readFile(settingsFile, "utf-8");
|
|
138
|
+
const section = parseDocument(text).toJS()[ns];
|
|
287
139
|
if (typeof section !== "object" || section === null) return void 0;
|
|
288
140
|
return section;
|
|
289
141
|
}
|
|
@@ -312,7 +164,7 @@ function installLlmFallback(ctx, deps) {
|
|
|
312
164
|
return;
|
|
313
165
|
}
|
|
314
166
|
const translated = translateProviders(providers);
|
|
315
|
-
const fbProvider = `${provider}
|
|
167
|
+
const fbProvider = `${provider}-fb`;
|
|
316
168
|
await writeWithRetry(() => writes.update(NS_PI_AI, { providers: translated }));
|
|
317
169
|
await writeWithRetry(() => writes.update(NS_AGENT_DEFAULT, {
|
|
318
170
|
provider: fbProvider,
|
|
@@ -356,7 +208,8 @@ function installLlmFallback(ctx, deps) {
|
|
|
356
208
|
await activate(def.provider, def.model);
|
|
357
209
|
}
|
|
358
210
|
const run = () => {
|
|
359
|
-
inFlight
|
|
211
|
+
if (inFlight !== void 0) return;
|
|
212
|
+
inFlight = evaluate().catch((error) => {
|
|
360
213
|
const message = error instanceof Error ? error.message : String(error);
|
|
361
214
|
logger.warn(`llm fallback 评估失败: ${message}`);
|
|
362
215
|
deps.journal("llm-fallback-error", message);
|
|
@@ -367,7 +220,7 @@ function installLlmFallback(ctx, deps) {
|
|
|
367
220
|
const bootTimer = setTimeout(run, 1e4);
|
|
368
221
|
ctx.effect(() => () => clearTimeout(bootTimer), "lifeboat: fallback boot timer");
|
|
369
222
|
ctx.on("ready", run);
|
|
370
|
-
const WATCHED_NS = new Set([
|
|
223
|
+
const WATCHED_NS = /* @__PURE__ */ new Set([
|
|
371
224
|
"agent-default-model",
|
|
372
225
|
"llm-pi-ai",
|
|
373
226
|
RAW_NS_LLM_PI
|
|
@@ -377,13 +230,182 @@ function installLlmFallback(ctx, deps) {
|
|
|
377
230
|
});
|
|
378
231
|
ctx.root.on("llm/adapters-updated", run);
|
|
379
232
|
}
|
|
380
|
-
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region src/notify.ts
|
|
235
|
+
/**
|
|
236
|
+
* 装配告警出口。返回同步派单函数:内部异步发送,失败只记日志。
|
|
237
|
+
* notify-email 出现时升级为邮件;全程 journal 兜底可溯。
|
|
238
|
+
*/
|
|
239
|
+
function installAlerter(ctx, journal) {
|
|
240
|
+
const logger = ctx.logger("lifeboat");
|
|
241
|
+
let mailer;
|
|
242
|
+
ctx.inject(["notifyEmail"], (mailCtx) => {
|
|
243
|
+
mailer = mailCtx.notifyEmail;
|
|
244
|
+
return () => {
|
|
245
|
+
mailer = void 0;
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
return (subject, text) => {
|
|
249
|
+
journal("alert", `${subject} | ${text}`);
|
|
250
|
+
const active = mailer;
|
|
251
|
+
if (!active?.sendNotice) {
|
|
252
|
+
logger.warn(`${subject} —— ${text}`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
active.sendNotice({
|
|
256
|
+
subject,
|
|
257
|
+
text
|
|
258
|
+
}).then((result) => {
|
|
259
|
+
if (!result.ok) logger.warn(`邮件告警投递失败: ${result.detail}`);
|
|
260
|
+
}, (error) => {
|
|
261
|
+
logger.warn(`邮件告警异常: ${error instanceof Error ? error.message : String(error)}`);
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region src/patch-file.ts
|
|
267
|
+
/**
|
|
268
|
+
* profile 用户 patch 文件(cordis.patch.yml)的安全写入。
|
|
269
|
+
* 官方预留的逃生门:用户层支持 id 定向 disabled,覆盖 bundle 层 insert。
|
|
270
|
+
* 写入纪律:先备份、幂等(已禁用跳过)、原子落盘(tmp + rename),失败抛错由调用方告警,
|
|
271
|
+
* 绝不写半个文件、绝不改动既有条目内容。
|
|
272
|
+
* @module lifeboat/patch-file
|
|
273
|
+
*/
|
|
274
|
+
/** 条目(含 insert 子列表)中是否已有针对 targetId 的 disabled 覆盖。 */
|
|
275
|
+
function hasDisable(entries, targetId) {
|
|
276
|
+
for (const entry of entries) {
|
|
277
|
+
if (entry.id === targetId && entry.disabled === true) return true;
|
|
278
|
+
if (Array.isArray(entry.insert) && hasDisable(entry.insert, targetId)) return true;
|
|
279
|
+
}
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* 向 patch 文件追加一条禁用覆盖;返回是否实际写入(已存在禁用则跳过)。
|
|
284
|
+
* @throws 文件不可读/顶层不是数组/落盘失败——调用方负责告警。
|
|
285
|
+
*/
|
|
286
|
+
async function appendDisable(patchFile, targetId) {
|
|
287
|
+
const text = await readFile(patchFile, "utf-8");
|
|
288
|
+
const doc = parseDocument(text);
|
|
289
|
+
const root = doc.toJS();
|
|
290
|
+
if (!Array.isArray(root)) throw new Error(`patch 文件顶层不是数组: ${patchFile}`);
|
|
291
|
+
if (hasDisable(root, targetId)) return false;
|
|
292
|
+
await copyFile(patchFile, `${patchFile}.lifeboat.bak`);
|
|
293
|
+
doc.add({
|
|
294
|
+
id: targetId,
|
|
295
|
+
disabled: true
|
|
296
|
+
});
|
|
297
|
+
const tmp = `${patchFile}.lifeboat.tmp`;
|
|
298
|
+
await writeFile(tmp, doc.toString(), "utf-8");
|
|
299
|
+
await rename(tmp, patchFile);
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region src/quarantine.ts
|
|
304
|
+
/** Runtime mirror: FiberState 是 cordis 跨包 const enum(官方 dsh-host-plugin-inventory 同款做法)。 */
|
|
305
|
+
const FIBER_FAILED = 3;
|
|
306
|
+
/** 永不隔离的条目:救生艇自身与聚合 bundle(禁了它们等于关掉整个防线/全家)。 */
|
|
307
|
+
const EXCLUDED = /* @__PURE__ */ new Set(["dsh-plus-lifeboat", "dsh-plus-bundle-main"]);
|
|
308
|
+
const GUARD_RE = /^dsh-plus-[a-z0-9-]+$/;
|
|
309
|
+
/** 名字是否属于可隔离的 dsh-plus 兄弟插件(entry id 与插件 name 同字面量是本仓库约定)。 */
|
|
310
|
+
function isGuardedPlugin(name) {
|
|
311
|
+
return typeof name === "string" && GUARD_RE.test(name) && !EXCLUDED.has(name);
|
|
312
|
+
}
|
|
313
|
+
/** 告警/journal 里的失败原因摘录上限,防长堆栈淹没通知渠道。 */
|
|
314
|
+
const REASON_CAP = 500;
|
|
315
|
+
/** 提取 fiber 失败原因的短文本(无则空串);cordis 把错误挂在 fiber._error。 */
|
|
316
|
+
function reasonText(reason) {
|
|
317
|
+
if (reason === void 0 || reason === null) return "";
|
|
318
|
+
const text = reason instanceof Error ? reason.stack ?? reason.message : String(reason);
|
|
319
|
+
return text.length > REASON_CAP ? `${text.slice(0, REASON_CAP)}…` : text;
|
|
320
|
+
}
|
|
321
|
+
/** 装配隔离执行器(纯逻辑,可测)。 */
|
|
322
|
+
function createQuarantine(ctx, deps) {
|
|
323
|
+
const logger = ctx.logger("lifeboat");
|
|
324
|
+
const lastAlertAt = /* @__PURE__ */ new Map();
|
|
325
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
326
|
+
async function quarantine(name, origin, reason) {
|
|
327
|
+
if (!isGuardedPlugin(name)) return;
|
|
328
|
+
if (inFlight.has(name)) return;
|
|
329
|
+
inFlight.add(name);
|
|
330
|
+
const reasonBlock = reasonText(reason);
|
|
331
|
+
const reasonSuffix = reasonBlock === "" ? "" : `\n失败原因:\n${reasonBlock}`;
|
|
332
|
+
try {
|
|
333
|
+
const written = await appendDisable(deps.patchFile, name);
|
|
334
|
+
deps.journal("quarantine", `${name}(来源 ${origin},${written ? "已写入禁用" : "已存在禁用"})${reasonSuffix}`);
|
|
335
|
+
const now = Date.now();
|
|
336
|
+
if (now - (lastAlertAt.get(name) ?? 0) >= deps.alertCooldownMs) {
|
|
337
|
+
lastAlertAt.set(name, now);
|
|
338
|
+
deps.alert(`[DSH] 插件 ${name} 加载失败,已自动隔离`, `检测到插件 ${name} 的 fiber 进入 FAILED(来源:${origin})。\n已向 ${deps.patchFile} 写入 disabled 覆盖,重启/刷新后该插件不再加载,其余插件不受影响。\n修复后删除该条覆盖即可恢复。${reasonSuffix}`);
|
|
339
|
+
}
|
|
340
|
+
} catch (error) {
|
|
341
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
342
|
+
logger.warn(`隔离 ${name} 失败: ${message}`);
|
|
343
|
+
deps.journal("quarantine-error", `${name}: ${message}`);
|
|
344
|
+
deps.alert(`[DSH] 插件 ${name} 加载失败,自动隔离未果`, `隔离写入失败:${message}\n请手动在 patch 层禁用该插件。`);
|
|
345
|
+
} finally {
|
|
346
|
+
inFlight.delete(name);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return { quarantine };
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* host 侧监听:root context 上兄弟 fiber 的 internal/status 转移。
|
|
353
|
+
* fiber.name 对 loader 条目即插件 display name(模块导出的 name 字段)。
|
|
354
|
+
*/
|
|
355
|
+
function installHostWatch(ctx, q) {
|
|
356
|
+
ctx.root.on("internal/status", (fiber) => {
|
|
357
|
+
if (fiber.state !== FIBER_FAILED) return;
|
|
358
|
+
if (!isGuardedPlugin(fiber.name)) return;
|
|
359
|
+
q.quarantine(fiber.name, "host", fiber._error);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region src/quarantine-api.ts
|
|
364
|
+
const ROUTE = "/dsh-plus/lifeboat/quarantine";
|
|
365
|
+
function sendJson(res, status, body) {
|
|
366
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
367
|
+
res.end(JSON.stringify(body));
|
|
368
|
+
}
|
|
369
|
+
function readBody(req) {
|
|
370
|
+
return new Promise((resolve, reject) => {
|
|
371
|
+
const chunks = [];
|
|
372
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
373
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
374
|
+
req.on("error", reject);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
/** 注册哨兵回报路由(webServer 缺失时由调用方保证不调用)。 */
|
|
378
|
+
function registerQuarantineApi(ctx, q) {
|
|
379
|
+
const logger = ctx.logger("lifeboat");
|
|
380
|
+
ctx.webServer.register({
|
|
381
|
+
kind: "prefix",
|
|
382
|
+
path: ROUTE,
|
|
383
|
+
handler: async (req, res) => {
|
|
384
|
+
try {
|
|
385
|
+
if (req.method !== "POST") {
|
|
386
|
+
sendJson(res, 405, { error: "POST only" });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const body = JSON.parse(await readBody(req));
|
|
390
|
+
if (typeof body.name !== "string") {
|
|
391
|
+
sendJson(res, 400, { error: "missing name" });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
q.quarantine(body.name, "client");
|
|
395
|
+
sendJson(res, 200, { ok: true });
|
|
396
|
+
} catch (error) {
|
|
397
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
398
|
+
logger.warn(`quarantine api failed: ${message}`);
|
|
399
|
+
if (!res.headersSent) sendJson(res, 400, { error: message });
|
|
400
|
+
else res.end();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
}
|
|
381
405
|
//#endregion
|
|
382
406
|
//#region src/index.ts
|
|
383
407
|
const name = "dsh-plus-lifeboat";
|
|
384
408
|
const inject = ["settings", "llm"];
|
|
385
|
-
/** journal 上限:只留最近 50 条,防长期运行膨胀。 */
|
|
386
|
-
const JOURNAL_CAP = 50;
|
|
387
409
|
function apply(ctx, config) {
|
|
388
410
|
const logger = ctx.logger("lifeboat");
|
|
389
411
|
let doc = {
|
|
@@ -405,7 +427,7 @@ function apply(ctx, config) {
|
|
|
405
427
|
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
406
428
|
kind,
|
|
407
429
|
detail
|
|
408
|
-
}].slice(-
|
|
430
|
+
}].slice(-50)
|
|
409
431
|
};
|
|
410
432
|
persist?.({ journal: doc.journal }).catch((error) => {
|
|
411
433
|
logger.warn(`journal 持久化失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -437,6 +459,5 @@ function apply(ctx, config) {
|
|
|
437
459
|
}
|
|
438
460
|
});
|
|
439
461
|
}
|
|
440
|
-
|
|
441
462
|
//#endregion
|
|
442
|
-
export { Config, apply, inject, name };
|
|
463
|
+
export { Config, apply, inject, name };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plus/lifeboat",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "dsh-plus service plugin: 故障救生艇——兄弟插件崩溃时自动隔离(写用户 patch 层禁用),llm-pi 缺席时把其配置临时翻译为官方 llm-pi-ai 路由,恢复后自动还原",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -38,10 +38,10 @@
|
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@deepseek-ai/cordis": "4.0.1",
|
|
41
|
-
"@deepseek-ai/dsh-home-paths": "0.1.
|
|
42
|
-
"@deepseek-ai/dsh-host-webserver": "0.1.
|
|
43
|
-
"@deepseek-ai/dsh-llm": "0.1.
|
|
44
|
-
"@deepseek-ai/dsh-settings": "0.1.
|
|
41
|
+
"@deepseek-ai/dsh-home-paths": "0.1.1-rc.2",
|
|
42
|
+
"@deepseek-ai/dsh-host-webserver": "0.1.1-rc.2",
|
|
43
|
+
"@deepseek-ai/dsh-llm": "0.1.1-rc.2",
|
|
44
|
+
"@deepseek-ai/dsh-settings": "0.1.1-rc.2",
|
|
45
45
|
"@deepseek-ai/schemastery": "3.18.1"
|
|
46
46
|
},
|
|
47
47
|
"publishConfig": {
|
package/src/config.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* schema 刻意宽松:lifeboat 是最后防线,自身命名空间的数据问题绝不能让它起不来。
|
|
7
7
|
* @module lifeboat/config
|
|
8
8
|
*/
|
|
9
|
-
|
|
9
|
+
|
|
10
10
|
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
11
|
+
import z from '@deepseek-ai/schemastery'
|
|
11
12
|
|
|
12
13
|
import { SETTINGS_NS as NS_LITERAL } from './ns.ts'
|
|
13
14
|
|
|
@@ -17,11 +18,16 @@ export const SETTINGS_NS = settingsNamespace(NS_LITERAL)
|
|
|
17
18
|
/** 行级配置:全部有默认值,正常部署零配置。 */
|
|
18
19
|
export const Config = z.object({
|
|
19
20
|
enabled: z.boolean().description('故障隔离总开关').default(true),
|
|
20
|
-
patchFile: z
|
|
21
|
-
.
|
|
21
|
+
patchFile: z
|
|
22
|
+
.string()
|
|
23
|
+
.description(
|
|
24
|
+
'隔离写入的 profile 用户 patch 文件绝对路径;留空自动取 $DSH_HOME/profiles/web/cordis.patch.yml',
|
|
25
|
+
)
|
|
22
26
|
.default(''),
|
|
23
|
-
llmFallback: z
|
|
24
|
-
.
|
|
27
|
+
llmFallback: z
|
|
28
|
+
.boolean()
|
|
29
|
+
.description('llm-pi 缺席时自动翻译其配置到官方 llm-pi-ai 并切换默认模型')
|
|
30
|
+
.default(true),
|
|
25
31
|
alertCooldownMs: z.natural().description('同一插件重复告警的最小间隔毫秒数').default(300000),
|
|
26
32
|
})
|
|
27
33
|
|
|
@@ -47,7 +53,10 @@ const FallbackState = z.object({
|
|
|
47
53
|
/** 命名空间 schema:宽松承载 journal 与翻译状态,上限截断防膨胀。 */
|
|
48
54
|
export const JournalSchema = z.object({
|
|
49
55
|
journal: z.array(JournalEntry).description('救生艇操作日志(最新在尾,封顶 50 条)').default([]),
|
|
50
|
-
llmFallback: z
|
|
56
|
+
llmFallback: z
|
|
57
|
+
.union([FallbackState, z.const(null)])
|
|
58
|
+
.description('LLM 应急翻译状态;null 表示未处于降级')
|
|
59
|
+
.default(null),
|
|
51
60
|
})
|
|
52
61
|
|
|
53
62
|
export type JournalDoc = Schemastery.TypeT<typeof JournalSchema>
|
package/src/fallback-llm.ts
CHANGED
|
@@ -15,7 +15,11 @@ import { readFile } from 'node:fs/promises'
|
|
|
15
15
|
import type { Context } from '@deepseek-ai/cordis'
|
|
16
16
|
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
|
|
17
17
|
import type {} from '@deepseek-ai/dsh-llm'
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
SettingsConflictError,
|
|
20
|
+
type SettingsProvider,
|
|
21
|
+
settingsNamespace,
|
|
22
|
+
} from '@deepseek-ai/dsh-settings'
|
|
19
23
|
import { parseDocument } from 'yaml'
|
|
20
24
|
|
|
21
25
|
import type { FallbackStateT } from './config.ts'
|
|
@@ -100,7 +104,8 @@ export function inferApi(routeKey: string, provider: RawProvider): string {
|
|
|
100
104
|
if (routeHint !== undefined) return routeHint
|
|
101
105
|
const firstExtends = provider.models?.find((m) => typeof m.extends === 'string')?.extends
|
|
102
106
|
const source = firstExtends?.split('/')[0]
|
|
103
|
-
if (source !== undefined && EXTENDS_PROTOCOL[source] !== undefined)
|
|
107
|
+
if (source !== undefined && EXTENDS_PROTOCOL[source] !== undefined)
|
|
108
|
+
return EXTENDS_PROTOCOL[source]
|
|
104
109
|
return 'openai-completions'
|
|
105
110
|
}
|
|
106
111
|
|
|
@@ -113,9 +118,11 @@ export function translateProvider(routeKey: string, raw: RawProvider): Translate
|
|
|
113
118
|
if (raw.apiKeyEnv !== undefined) out.apiKeyEnv = raw.apiKeyEnv
|
|
114
119
|
if (raw.baseURL !== undefined) out.baseURL = raw.baseURL
|
|
115
120
|
if (raw.headers !== undefined) out.headers = raw.headers
|
|
116
|
-
if (raw.reasoning !== undefined && REASONING_LEVELS.has(raw.reasoning))
|
|
121
|
+
if (raw.reasoning !== undefined && REASONING_LEVELS.has(raw.reasoning))
|
|
122
|
+
out.reasoning = raw.reasoning
|
|
117
123
|
if (raw.thinkingBudgets !== undefined) out.thinkingBudgets = raw.thinkingBudgets
|
|
118
|
-
if (Array.isArray(raw.defaultInput) && raw.defaultInput.length > 0)
|
|
124
|
+
if (Array.isArray(raw.defaultInput) && raw.defaultInput.length > 0)
|
|
125
|
+
out.defaultInput = raw.defaultInput
|
|
119
126
|
if (raw.compat !== undefined) {
|
|
120
127
|
const compat: Record<string, unknown> = {}
|
|
121
128
|
for (const key of COMPAT_WHITELIST) {
|
|
@@ -137,7 +144,9 @@ export function translateProvider(routeKey: string, raw: RawProvider): Translate
|
|
|
137
144
|
}
|
|
138
145
|
|
|
139
146
|
/** 整表翻译:键加 -fb 后缀(纯函数)。 */
|
|
140
|
-
export function translateProviders(
|
|
147
|
+
export function translateProviders(
|
|
148
|
+
providers: Record<string, RawProvider>,
|
|
149
|
+
): Record<string, TranslatedProvider> {
|
|
141
150
|
const out: Record<string, TranslatedProvider> = {}
|
|
142
151
|
for (const [key, raw] of Object.entries(providers)) {
|
|
143
152
|
out[`${key}${FALLBACK_SUFFIX}`] = translateProvider(key, raw)
|
|
@@ -146,7 +155,10 @@ export function translateProviders(providers: Record<string, RawProvider>): Reco
|
|
|
146
155
|
}
|
|
147
156
|
|
|
148
157
|
/** 读 settings.yaml 原始文档的指定段落(只读;文件/段落缺失返回 undefined)。 */
|
|
149
|
-
export async function readRawSection(
|
|
158
|
+
export async function readRawSection(
|
|
159
|
+
settingsFile: string,
|
|
160
|
+
ns: string,
|
|
161
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
150
162
|
const text = await readFile(settingsFile, 'utf-8')
|
|
151
163
|
const doc = parseDocument(text).toJS() as Record<string, unknown>
|
|
152
164
|
const section = doc[ns]
|
|
@@ -156,7 +168,14 @@ export async function readRawSection(settingsFile: string, ns: string): Promise<
|
|
|
156
168
|
|
|
157
169
|
interface SettingsWrite {
|
|
158
170
|
update(ns: ReturnType<typeof settingsNamespace>, patch: object): Promise<void>
|
|
159
|
-
mutate(
|
|
171
|
+
mutate(
|
|
172
|
+
ns: ReturnType<typeof settingsNamespace>,
|
|
173
|
+
ops: readonly {
|
|
174
|
+
op: 'set' | 'unset'
|
|
175
|
+
path: readonly string[]
|
|
176
|
+
value?: unknown
|
|
177
|
+
}[],
|
|
178
|
+
): Promise<void>
|
|
160
179
|
}
|
|
161
180
|
|
|
162
181
|
/** 写 settings 带一次冲突重试(用户同时改配置时不抢,放弃并告警)。
|
|
@@ -168,7 +187,9 @@ async function writeWithRetry(write: () => Promise<void>): Promise<void> {
|
|
|
168
187
|
} catch (error) {
|
|
169
188
|
const conflict =
|
|
170
189
|
error instanceof SettingsConflictError ||
|
|
171
|
-
(typeof error === 'object' &&
|
|
190
|
+
(typeof error === 'object' &&
|
|
191
|
+
error !== null &&
|
|
192
|
+
(error as { code?: unknown }).code === 'SETTINGS_CONFLICT')
|
|
172
193
|
if (!conflict) throw error
|
|
173
194
|
await write()
|
|
174
195
|
}
|
|
@@ -190,8 +211,7 @@ export function installLlmFallback(ctx: Context, deps: FallbackDeps): void {
|
|
|
190
211
|
const settingsFile = dshHomePath('settings.yaml')
|
|
191
212
|
let inFlight: Promise<void> | undefined
|
|
192
213
|
|
|
193
|
-
const listProviderIds = (): Set<string> =>
|
|
194
|
-
new Set(ctx.llm.listProviders().map((p) => p.id))
|
|
214
|
+
const listProviderIds = (): Set<string> => new Set(ctx.llm.listProviders().map((p) => p.id))
|
|
195
215
|
|
|
196
216
|
async function activate(provider: string, model: string): Promise<void> {
|
|
197
217
|
const section = await readRawSection(settingsFile, RAW_NS_LLM_PI).catch(() => undefined)
|
|
@@ -215,7 +235,10 @@ export function installLlmFallback(ctx: Context, deps: FallbackDeps): void {
|
|
|
215
235
|
providers: Object.keys(translated),
|
|
216
236
|
at: new Date().toISOString(),
|
|
217
237
|
})
|
|
218
|
-
deps.journal(
|
|
238
|
+
deps.journal(
|
|
239
|
+
'llm-fallback',
|
|
240
|
+
`默认模型切换到 ${fbProvider}/${model}(翻译 ${Object.keys(translated).length} 个 provider)`,
|
|
241
|
+
)
|
|
219
242
|
deps.alert(
|
|
220
243
|
'[DSH] LLM 配置已应急翻译',
|
|
221
244
|
`默认模型的 provider "${provider}" 不可用(llm-pi 缺席?),已把 ${RAW_NS_LLM_PI} 配置翻译为官方 llm-pi-ai 路由(-fb 后缀)并切换默认模型为 ${fbProvider}/${model}。源插件恢复后将自动还原。`,
|
|
@@ -223,13 +246,22 @@ export function installLlmFallback(ctx: Context, deps: FallbackDeps): void {
|
|
|
223
246
|
}
|
|
224
247
|
|
|
225
248
|
async function revert(state: FallbackStateT): Promise<void> {
|
|
226
|
-
const ops = state.providers.map((key) => ({
|
|
249
|
+
const ops = state.providers.map((key) => ({
|
|
250
|
+
op: 'unset' as const,
|
|
251
|
+
path: ['providers', key],
|
|
252
|
+
}))
|
|
227
253
|
await writeWithRetry(() => writes.mutate(NS_PI_AI, ops))
|
|
228
254
|
await writeWithRetry(() =>
|
|
229
|
-
writes.update(NS_AGENT_DEFAULT, {
|
|
255
|
+
writes.update(NS_AGENT_DEFAULT, {
|
|
256
|
+
provider: state.originalProvider,
|
|
257
|
+
model: state.originalModel,
|
|
258
|
+
}),
|
|
230
259
|
)
|
|
231
260
|
await deps.writeState(null)
|
|
232
|
-
deps.journal(
|
|
261
|
+
deps.journal(
|
|
262
|
+
'llm-fallback-revert',
|
|
263
|
+
`默认模型还原为 ${state.originalProvider}/${state.originalModel}`,
|
|
264
|
+
)
|
|
233
265
|
deps.alert(
|
|
234
266
|
'[DSH] LLM 应急翻译已还原',
|
|
235
267
|
`provider "${state.originalProvider}" 恢复健康,默认模型已还原,临时翻译路由已清除。`,
|
|
@@ -243,14 +275,17 @@ export function installLlmFallback(ctx: Context, deps: FallbackDeps): void {
|
|
|
243
275
|
if (ids.has(state.originalProvider)) await revert(state)
|
|
244
276
|
return
|
|
245
277
|
}
|
|
246
|
-
const def = ctx.settings.get(NS_AGENT_DEFAULT) as
|
|
278
|
+
const def = ctx.settings.get(NS_AGENT_DEFAULT) as
|
|
279
|
+
| { provider?: string; model?: string }
|
|
280
|
+
| undefined
|
|
247
281
|
if (def?.provider === undefined || def.model === undefined) return
|
|
248
282
|
if (ids.has(def.provider)) return
|
|
249
283
|
await activate(def.provider, def.model)
|
|
250
284
|
}
|
|
251
285
|
|
|
252
286
|
const run = (): void => {
|
|
253
|
-
inFlight
|
|
287
|
+
if (inFlight !== undefined) return
|
|
288
|
+
inFlight = evaluate()
|
|
254
289
|
.catch((error: unknown) => {
|
|
255
290
|
const message = error instanceof Error ? error.message : String(error)
|
|
256
291
|
logger.warn(`llm fallback 评估失败: ${message}`)
|
|
@@ -264,10 +299,7 @@ export function installLlmFallback(ctx: Context, deps: FallbackDeps): void {
|
|
|
264
299
|
// 首次评估延迟到启动收尾:lifeboat 首位加载时兄弟插件的 adapter 尚未注册,
|
|
265
300
|
// 立刻评估会把"还没加载"误判为"缺席"。事件订阅覆盖后续变化。
|
|
266
301
|
const bootTimer = setTimeout(run, 10_000)
|
|
267
|
-
ctx.effect(
|
|
268
|
-
() => () => clearTimeout(bootTimer),
|
|
269
|
-
'lifeboat: fallback boot timer',
|
|
270
|
-
)
|
|
302
|
+
ctx.effect(() => () => clearTimeout(bootTimer), 'lifeboat: fallback boot timer')
|
|
271
303
|
ctx.on('ready' as never, run)
|
|
272
304
|
// 只响应与本判定相关的命名空间变化:journal 写自身命名空间,不过滤会自触发循环。
|
|
273
305
|
const WATCHED_NS = new Set(['agent-default-model', 'llm-pi-ai', RAW_NS_LLM_PI])
|
package/src/index.ts
CHANGED
|
@@ -15,11 +15,18 @@
|
|
|
15
15
|
import type { Context } from '@deepseek-ai/cordis'
|
|
16
16
|
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
Config,
|
|
20
|
+
type FallbackStateT,
|
|
21
|
+
type JournalDoc,
|
|
22
|
+
JournalSchema,
|
|
23
|
+
type LifeboatConfig,
|
|
24
|
+
SETTINGS_NS,
|
|
25
|
+
} from './config.ts'
|
|
26
|
+
import { installLlmFallback } from './fallback-llm.ts'
|
|
19
27
|
import { installAlerter } from './notify.ts'
|
|
20
28
|
import { createQuarantine, installHostWatch } from './quarantine.ts'
|
|
21
29
|
import { registerQuarantineApi } from './quarantine-api.ts'
|
|
22
|
-
import { installLlmFallback } from './fallback-llm.ts'
|
|
23
30
|
|
|
24
31
|
export const name = 'dsh-plus-lifeboat'
|
|
25
32
|
|
|
@@ -42,11 +49,16 @@ export function apply(ctx: Context, config: LifeboatConfig): void {
|
|
|
42
49
|
doc = scope.get()
|
|
43
50
|
persist = (patch) => scope.update(patch)
|
|
44
51
|
} catch (error) {
|
|
45
|
-
logger.warn(
|
|
52
|
+
logger.warn(
|
|
53
|
+
`journal 命名空间注册失败,降级为内存态: ${error instanceof Error ? error.message : String(error)}`,
|
|
54
|
+
)
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
const journal = (kind: string, detail: string): void => {
|
|
49
|
-
doc = {
|
|
58
|
+
doc = {
|
|
59
|
+
...doc,
|
|
60
|
+
journal: [...doc.journal, { at: new Date().toISOString(), kind, detail }].slice(-JOURNAL_CAP),
|
|
61
|
+
}
|
|
50
62
|
void persist?.({ journal: doc.journal }).catch((error: unknown) => {
|
|
51
63
|
logger.warn(`journal 持久化失败: ${error instanceof Error ? error.message : String(error)}`)
|
|
52
64
|
})
|
|
@@ -55,9 +67,10 @@ export function apply(ctx: Context, config: LifeboatConfig): void {
|
|
|
55
67
|
const alert = installAlerter(ctx, journal)
|
|
56
68
|
|
|
57
69
|
if (config.enabled) {
|
|
58
|
-
const patchFile =
|
|
59
|
-
|
|
60
|
-
|
|
70
|
+
const patchFile =
|
|
71
|
+
config.patchFile.length > 0
|
|
72
|
+
? config.patchFile
|
|
73
|
+
: dshHomePath('profiles', 'web', 'cordis.patch.yml')
|
|
61
74
|
const quarantine = createQuarantine(ctx, {
|
|
62
75
|
patchFile,
|
|
63
76
|
alertCooldownMs: config.alertCooldownMs,
|
package/src/notify.ts
CHANGED
|
@@ -16,7 +16,10 @@ export type Alerter = (subject: string, text: string) => void
|
|
|
16
16
|
* 装配告警出口。返回同步派单函数:内部异步发送,失败只记日志。
|
|
17
17
|
* notify-email 出现时升级为邮件;全程 journal 兜底可溯。
|
|
18
18
|
*/
|
|
19
|
-
export function installAlerter(
|
|
19
|
+
export function installAlerter(
|
|
20
|
+
ctx: Context,
|
|
21
|
+
journal: (kind: string, detail: string) => void,
|
|
22
|
+
): Alerter {
|
|
20
23
|
const logger: Logger = ctx.logger('lifeboat')
|
|
21
24
|
let mailer: NotifyEmailLike | undefined
|
|
22
25
|
ctx.inject(['notifyEmail' as never], (mailCtx) => {
|
|
@@ -32,10 +35,13 @@ export function installAlerter(ctx: Context, journal: (kind: string, detail: str
|
|
|
32
35
|
logger.warn(`${subject} —— ${text}`)
|
|
33
36
|
return
|
|
34
37
|
}
|
|
35
|
-
void active.sendNotice({ subject, text }).then(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
void active.sendNotice({ subject, text }).then(
|
|
39
|
+
(result) => {
|
|
40
|
+
if (!result.ok) logger.warn(`邮件告警投递失败: ${result.detail}`)
|
|
41
|
+
},
|
|
42
|
+
(error: unknown) => {
|
|
43
|
+
logger.warn(`邮件告警异常: ${error instanceof Error ? error.message : String(error)}`)
|
|
44
|
+
},
|
|
45
|
+
)
|
|
40
46
|
}
|
|
41
47
|
}
|
package/src/quarantine.ts
CHANGED
|
@@ -31,7 +31,17 @@ export interface QuarantineDeps {
|
|
|
31
31
|
|
|
32
32
|
export interface Quarantine {
|
|
33
33
|
/** 隔离指定插件(幂等;冷却期内重复触发只计 journal 不写文件)。 */
|
|
34
|
-
quarantine(name: string, origin: 'host' | 'client'): Promise<void>
|
|
34
|
+
quarantine(name: string, origin: 'host' | 'client', reason?: unknown): Promise<void>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 告警/journal 里的失败原因摘录上限,防长堆栈淹没通知渠道。 */
|
|
38
|
+
const REASON_CAP = 500
|
|
39
|
+
|
|
40
|
+
/** 提取 fiber 失败原因的短文本(无则空串);cordis 把错误挂在 fiber._error。 */
|
|
41
|
+
function reasonText(reason: unknown): string {
|
|
42
|
+
if (reason === undefined || reason === null) return ''
|
|
43
|
+
const text = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)
|
|
44
|
+
return text.length > REASON_CAP ? `${text.slice(0, REASON_CAP)}…` : text
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
/** 装配隔离执行器(纯逻辑,可测)。 */
|
|
@@ -40,13 +50,22 @@ export function createQuarantine(ctx: Context, deps: QuarantineDeps): Quarantine
|
|
|
40
50
|
const lastAlertAt = new Map<string, number>()
|
|
41
51
|
const inFlight = new Set<string>()
|
|
42
52
|
|
|
43
|
-
async function quarantine(
|
|
53
|
+
async function quarantine(
|
|
54
|
+
name: string,
|
|
55
|
+
origin: 'host' | 'client',
|
|
56
|
+
reason?: unknown,
|
|
57
|
+
): Promise<void> {
|
|
44
58
|
if (!isGuardedPlugin(name)) return
|
|
45
59
|
if (inFlight.has(name)) return
|
|
46
60
|
inFlight.add(name)
|
|
61
|
+
const reasonBlock = reasonText(reason)
|
|
62
|
+
const reasonSuffix = reasonBlock === '' ? '' : `\n失败原因:\n${reasonBlock}`
|
|
47
63
|
try {
|
|
48
64
|
const written = await appendDisable(deps.patchFile, name)
|
|
49
|
-
deps.journal(
|
|
65
|
+
deps.journal(
|
|
66
|
+
'quarantine',
|
|
67
|
+
`${name}(来源 ${origin},${written ? '已写入禁用' : '已存在禁用'})${reasonSuffix}`,
|
|
68
|
+
)
|
|
50
69
|
const now = Date.now()
|
|
51
70
|
const last = lastAlertAt.get(name) ?? 0
|
|
52
71
|
if (now - last >= deps.alertCooldownMs) {
|
|
@@ -55,14 +74,17 @@ export function createQuarantine(ctx: Context, deps: QuarantineDeps): Quarantine
|
|
|
55
74
|
`[DSH] 插件 ${name} 加载失败,已自动隔离`,
|
|
56
75
|
`检测到插件 ${name} 的 fiber 进入 FAILED(来源:${origin})。\n` +
|
|
57
76
|
`已向 ${deps.patchFile} 写入 disabled 覆盖,重启/刷新后该插件不再加载,其余插件不受影响。\n` +
|
|
58
|
-
|
|
77
|
+
`修复后删除该条覆盖即可恢复。${reasonSuffix}`,
|
|
59
78
|
)
|
|
60
79
|
}
|
|
61
80
|
} catch (error) {
|
|
62
81
|
const message = error instanceof Error ? error.message : String(error)
|
|
63
82
|
logger.warn(`隔离 ${name} 失败: ${message}`)
|
|
64
83
|
deps.journal('quarantine-error', `${name}: ${message}`)
|
|
65
|
-
deps.alert(
|
|
84
|
+
deps.alert(
|
|
85
|
+
`[DSH] 插件 ${name} 加载失败,自动隔离未果`,
|
|
86
|
+
`隔离写入失败:${message}\n请手动在 patch 层禁用该插件。`,
|
|
87
|
+
)
|
|
66
88
|
} finally {
|
|
67
89
|
inFlight.delete(name)
|
|
68
90
|
}
|
|
@@ -76,9 +98,9 @@ export function createQuarantine(ctx: Context, deps: QuarantineDeps): Quarantine
|
|
|
76
98
|
* fiber.name 对 loader 条目即插件 display name(模块导出的 name 字段)。
|
|
77
99
|
*/
|
|
78
100
|
export function installHostWatch(ctx: Context, q: Quarantine): void {
|
|
79
|
-
ctx.root.on('internal/status', (fiber: { state: number; name: string }) => {
|
|
101
|
+
ctx.root.on('internal/status', (fiber: { state: number; name: string; _error?: unknown }) => {
|
|
80
102
|
if (fiber.state !== FIBER_FAILED) return
|
|
81
103
|
if (!isGuardedPlugin(fiber.name)) return
|
|
82
|
-
void q.quarantine(fiber.name, 'host')
|
|
104
|
+
void q.quarantine(fiber.name, 'host', fiber._error)
|
|
83
105
|
})
|
|
84
106
|
}
|