mingdao-harness 0.6.2 → 0.6.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/docs/AUDIT-v0.6.1-/347/254/254/344/270/211/346/226/271/346/212/245/345/221/212/347/231/273/350/256/260.md +300 -32
- package/docs/CONFIG.md +32 -6
- package/docs/DEVELOPER.md +1 -1
- package/docs/PACK-API.md +26 -3
- package/docs/PROVIDERS.md +22 -0
- package/docs/RELEASE-CHECKLIST.md +13 -2
- package/package.json +1 -1
- package/src/agent.js +133 -15
- package/src/atomic-write.js +247 -71
- package/src/audit.js +23 -18
- package/src/cachestats.js +57 -47
- package/src/cli.js +25 -4
- package/src/commands/diagnose.js +3 -0
- package/src/commands/key.js +23 -1
- package/src/commands/ledger.js +41 -3
- package/src/commands/net.js +5 -1
- package/src/commands/pack.js +23 -6
- package/src/commands/repl.js +9 -1
- package/src/commands/skill.js +30 -1
- package/src/commands/sync.js +7 -0
- package/src/commands/workspace.js +3 -3
- package/src/compact.js +15 -1
- package/src/config.js +71 -6
- package/src/constraints.js +55 -6
- package/src/credentials.js +36 -5
- package/src/hooks.js +37 -2
- package/src/ledger.js +3 -3
- package/src/log-writer.js +22 -17
- package/src/mcp.js +19 -1
- package/src/memory.js +8 -8
- package/src/packs.js +57 -8
- package/src/permissions.js +35 -9
- package/src/presets.js +46 -1
- package/src/providers/index.js +133 -7
- package/src/providers/openai-compatible.js +24 -3
- package/src/redact.js +38 -2
- package/src/replay.js +15 -3
- package/src/safe-fetch.js +8 -2
- package/src/schedule.js +22 -4
- package/src/session-index.js +2 -2
- package/src/session.js +2 -2
- package/src/skill-lib.js +43 -14
- package/src/skill-registry.js +31 -2
- package/src/sync-server.js +47 -28
- package/src/sync.js +17 -3
- package/src/task-state.js +10 -1
- package/src/tasks.js +22 -10
- package/src/tools/bash.js +96 -18
- package/src/tools/fetch.js +24 -0
- package/src/web/app.js +7 -7
- package/src/web/attachments.js +7 -1
- package/src/web/index.html +2 -2
- package/src/web/routes/api.js +27 -2
- package/src/web/routes/domains/sessions.js +3 -3
- package/src/web/routes/domains/sync.js +2 -1
- package/src/web/routes/domains/workspace.js +45 -15
- package/src/web/server.js +22 -5
- package/src/workspace.js +31 -18
package/src/atomic-write.js
CHANGED
|
@@ -4,12 +4,26 @@
|
|
|
4
4
|
// 存在丢更新与"rename 到对方半截文件"的成体系隐患;config/credentials 等关键文件更是直写。
|
|
5
5
|
// 本模块提供两个原语:
|
|
6
6
|
// 1. atomicWriteFileSync —— tmp 名含 pid+随机后缀(跨进程绝不共名),写完 rename 原子替换;
|
|
7
|
-
// 2. withFileLockSync —— O_EXCL lockfile
|
|
8
|
-
//
|
|
7
|
+
// 2. withFileLockSync —— O_EXCL lockfile 互斥(带超时与陈旧锁回收),包裹读-改-写序列;
|
|
8
|
+
// 3. withFileLock —— **同一套语义的异步版**:等待期间 `await` 让出事件循环,不阻塞 WebUI。
|
|
9
|
+
//
|
|
10
|
+
// v0.6.2(自评 P2-7 的阻塞面):同步版用 `Atomics.wait` 睡眠,**等待期间整个事件循环停摆**
|
|
11
|
+
// ——实测持锁方存活 2.6 秒时,一个 100ms 的定时器在锁返回前根本没触发。对常驻服务而言,
|
|
12
|
+
// 这意味着一次文件锁争用就会冻结所有并发会话/权限确认/SSE 流。因此新增异步版并把
|
|
13
|
+
// 「正好在请求路径上」的调用点迁过去(workspace 的 7 处);同步版保留给纯同步调用链。
|
|
14
|
+
//
|
|
15
|
+
// 另有一处**必须先修**的隐患:原可重入判据是**进程级 Set**。同步临界区不会 yield,
|
|
16
|
+
// 所以一直没出事;但异步临界区**会** yield——此时另一个任务拿同一把锁会被误判成"可重入"
|
|
17
|
+
// 而**并发**进入临界区,读-改-写互相覆盖。现在用 AsyncLocalStorage 把可重入限定在
|
|
18
|
+
// **同一条调用链**内:并发任务各持各的集合,嵌套调用复用同一份。
|
|
19
|
+
// 零依赖:仅 node:fs / node:path / node:crypto / node:async_hooks / Atomics.wait / timers/promises。
|
|
9
20
|
|
|
10
21
|
import fs from 'node:fs';
|
|
22
|
+
import { pidOwnedBy } from './proc.js';
|
|
11
23
|
import path from 'node:path';
|
|
12
24
|
import crypto from 'node:crypto';
|
|
25
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
26
|
+
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
|
13
27
|
import { procAlive } from './proc.js';
|
|
14
28
|
|
|
15
29
|
export function atomicWriteFileSync(/** @type {string} */ target, /** @type {string|Buffer} */ data, /** @type {any} */ options = {}) {
|
|
@@ -26,90 +40,252 @@ export function atomicWriteFileSync(/** @type {string} */ target, /** @type {str
|
|
|
26
40
|
}
|
|
27
41
|
}
|
|
28
42
|
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// 「私有文件」写入(v0.6.3,审计 H-1)
|
|
45
|
+
//
|
|
46
|
+
// 背景:会话/记忆/任务/调度/工作空间/索引这些文件此前直接 appendFileSync / 原子写**不带 mode**,
|
|
47
|
+
// 默认 umask 下是 0644——而会话原文会原样记录用户粘贴的 sk-*、PEM、JWT,同机其它用户可读。
|
|
48
|
+
// 同仓的账本/审计/凭据早已 0600,属于「同模块两套口径」。
|
|
49
|
+
//
|
|
50
|
+
// 两条纪律:
|
|
51
|
+
// ① 创建时带 mode: 0o600;
|
|
52
|
+
// ② **每次写都补一次 chmod 自愈**——mode 只在创建时生效,对旧版本留下的 0644 文件不起作用
|
|
53
|
+
// (凭据/审计早已这么做,这里收敛成共享助手,避免八处各写一遍再漂移)。
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 追加写入并保证 0600(对已存在文件收权自愈)。
|
|
58
|
+
* @param {string} file @param {string|Buffer} data
|
|
59
|
+
*/
|
|
60
|
+
export function appendFilePrivateSync(file, data) {
|
|
61
|
+
fs.appendFileSync(file, data, { mode: 0o600 });
|
|
62
|
+
try {
|
|
63
|
+
fs.chmodSync(file, 0o600);
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 原子写入并保证 0600(对已存在文件收权自愈)。
|
|
69
|
+
* @param {string} file @param {string|Buffer} data
|
|
70
|
+
*/
|
|
71
|
+
export function atomicWritePrivateSync(file, data) {
|
|
72
|
+
atomicWriteFileSync(file, data, { mode: 0o600 });
|
|
73
|
+
try {
|
|
74
|
+
fs.chmodSync(file, 0o600);
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
77
|
+
|
|
29
78
|
export function atomicWriteJsonSync(/** @type {string} */ target, /** @type {any} */ value, { mode = 0o600 } = {}) {
|
|
30
79
|
atomicWriteFileSync(target, JSON.stringify(value, null, 2) + '\n', { mode });
|
|
31
80
|
}
|
|
32
81
|
|
|
33
|
-
// 极简互斥锁:O_EXCL 创建 lockfile;持锁期间执行 fn
|
|
34
|
-
//
|
|
35
|
-
// 可重入(P0-1 修复,v0.4.5):本进程已持该锁时直接执行 fn——O_EXCL 锁不可重入,此前
|
|
36
|
-
// killTask 持锁内调 patchTask 二次抢同一把锁自死锁 5s(tasks kill/pause/remove 全失效)。
|
|
82
|
+
// 极简互斥锁:O_EXCL 创建 lockfile;持锁期间执行 fn(同步/异步);异常/完成释放。
|
|
83
|
+
// 进程崩溃遗留的陈旧锁自动回收,避免永久卡死。
|
|
37
84
|
const sleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
|
38
85
|
const sleepMs = (/** @type {number} */ ms) => Atomics.wait(sleepBuf, 0, 0, ms);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
86
|
+
|
|
87
|
+
// 可重入集合按**调用链**隔离(见文件头注释:异步临界区会 yield,进程级 Set 会让并发任务互相穿透)
|
|
88
|
+
const lockScope = new AsyncLocalStorage();
|
|
89
|
+
/** @param {(held: Set<string>) => any} fn */
|
|
90
|
+
function runWithHeld(fn) {
|
|
91
|
+
const held = lockScope.getStore();
|
|
92
|
+
if (held) return fn(held); // 已在同一条调用链里:复用同一份,嵌套即"可重入"
|
|
93
|
+
const fresh = new Set();
|
|
94
|
+
return lockScope.run(fresh, () => fn(fresh));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 尝试独占创建锁文件。EEXIST 表示别人持有(返回 false,由调用方决定等待或重试);
|
|
99
|
+
* 其它错误(权限/路径非法)**直接抛出**——那不是"有人在用",装成争用只会掩盖真问题。
|
|
100
|
+
* @param {string} lockPath
|
|
101
|
+
*/
|
|
102
|
+
function tryAcquire(lockPath) {
|
|
103
|
+
let fd;
|
|
104
|
+
try {
|
|
105
|
+
fd = fs.openSync(lockPath, 'wx');
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (/** @type {any} */ (err)?.code === 'EEXIST') return false;
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
// v0.6.3(M-11):锁内容额外记下本进程的**入口脚本**,作为 pid 的身份凭据。
|
|
112
|
+
// 起因:陈旧回收只看「持有者 pid 是否存活」,而 pid 会被复用——复用给一个活进程后
|
|
113
|
+
// `procAlive` 恒真,这把锁**永不回收**,所有写方等到超时失败(死锁)。
|
|
114
|
+
// 有了 cmd(argv[1]),回收方可以用既有的 pidOwnedBy() 校验"活着的这个 pid 还是不是原来那个进程"。
|
|
115
|
+
// v0.6.3(M-11):锁内容额外记下入口脚本的**文件名**作为 pid 的身份凭据。
|
|
116
|
+
// 起因:陈旧回收只看「持有者 pid 是否存活」,而 pid 会被复用——复用给一个活进程后
|
|
117
|
+
// `procAlive` 恒真,这把锁**永不回收**,所有写方等到超时失败(死锁)。
|
|
118
|
+
//
|
|
119
|
+
// 为什么是 basename 而不是 argv[1]:`ps` 显示的是**输入时的形态**,而 Node 把 argv[1]
|
|
120
|
+
// 解析成绝对路径——`node test/smoke.js` 在 ps 里就是 `node test/smoke.js`。
|
|
121
|
+
// 第一版存 argv[1],于是"同进程内的并发任务"也被判成 pid 复用 → **把活锁抢走**,
|
|
122
|
+
// 既有的并发串行断言当场抓到(3 次自增只剩 1)。basename 在两种形态下都出现,是稳妥的交集。
|
|
123
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now(), cmd: path.basename(process.argv[1] || process.execPath) }));
|
|
124
|
+
} finally {
|
|
125
|
+
fs.closeSync(fd);
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @param {string} lockPath */
|
|
131
|
+
function releaseLock(lockPath) {
|
|
132
|
+
try {
|
|
133
|
+
fs.unlinkSync(lockPath);
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 判断能否回收一把陈旧锁(返回 true = 调用方应立刻重试)。
|
|
139
|
+
*
|
|
140
|
+
* v0.6.2(P2-7):陈旧判据是「**持有者 pid 已死**」优先,而不是只看 mtime:
|
|
141
|
+
* · 只看 mtime 会带来两个反向问题:① 崩溃后要白等满 staleMs 才允许回收(配小 timeout 就是死区);
|
|
142
|
+
* ② 某个 fn 本身耗时超过 staleMs(大文件重写、慢盘)时,别人会把**仍然活着**的锁判成陈旧并回收
|
|
143
|
+
* ——互斥直接失效,且没有任何补救。
|
|
144
|
+
* · 锁内容本来就写着 {pid, at},现成的判据要用上:持有者还活着(哪怕 fn 跑了很久)**绝不回收**。
|
|
145
|
+
* 读不到持有者信息(老格式/内容损坏)时才退回「mtime 超过 staleMs」。
|
|
146
|
+
* @param {string} lockPath @param {number} staleMs
|
|
147
|
+
*/
|
|
148
|
+
function reclaimIfStale(lockPath, staleMs) {
|
|
149
|
+
let st;
|
|
150
|
+
try {
|
|
151
|
+
st = fs.statSync(lockPath);
|
|
152
|
+
} catch {
|
|
153
|
+
return true; // 锁文件刚被释放:立刻重试
|
|
49
154
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
155
|
+
let holderPid = 0;
|
|
156
|
+
let holderCmd = '';
|
|
157
|
+
try {
|
|
158
|
+
const meta = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
159
|
+
holderPid = Number(meta?.pid) || 0;
|
|
160
|
+
holderCmd = String(meta?.cmd || '');
|
|
161
|
+
} catch {}
|
|
162
|
+
let holderAlive = holderPid > 0 ? procAlive(holderPid) : null;
|
|
163
|
+
// M-11:pid 存活 ≠ 原持有者还活着。用命令行归属校验识破 pid 复用:
|
|
164
|
+
// · pidOwnedBy 返回 false → 这个 pid 现在跑的是**别的**程序 → 原持有者已死,可以回收;
|
|
165
|
+
// · 返回 true(同一入口脚本,可能是也可能不是原进程)或 null(Windows/取不到命令行)→ 保持"活着"。
|
|
166
|
+
// 这一层是**严格改进**:原先这种情况一律不回收,现在只有"能确证不是原进程"时才回收。
|
|
167
|
+
if (holderAlive === true && holderCmd) {
|
|
57
168
|
try {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
169
|
+
if (pidOwnedBy(holderPid, holderCmd) === false) holderAlive = false;
|
|
170
|
+
} catch {}
|
|
171
|
+
}
|
|
172
|
+
const reclaimable = holderAlive === false || (holderAlive === null && Date.now() - st.mtimeMs > staleMs);
|
|
173
|
+
if (!reclaimable) return false;
|
|
174
|
+
// TOCTOU 防护(OfficeACE 报告):unlink 前读锁内容并二次 stat 比对,
|
|
175
|
+
// 防两个进程同时判定陈旧、后者误删前者刚创建的新锁(互斥失效)
|
|
176
|
+
try {
|
|
177
|
+
const before = fs.readFileSync(lockPath, 'utf8');
|
|
178
|
+
const st2 = fs.statSync(lockPath);
|
|
179
|
+
if (st2.mtimeMs === st.mtimeMs && before === fs.readFileSync(lockPath, 'utf8')) {
|
|
180
|
+
fs.unlinkSync(lockPath);
|
|
181
|
+
}
|
|
182
|
+
} catch {}
|
|
183
|
+
return true; // 无论是否回收成功都重试一次(若锁刚被他人更新则继续等待)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 锁的两个时间参数(v0.6.2 依实测重新定过):
|
|
187
|
+
//
|
|
188
|
+
// ① **必须 timeoutMs > staleMs**。曾是 5000/15000——等待方 5 秒就抛超时,而陈旧锁要 15 秒才
|
|
189
|
+
// 允许回收,中间 10 秒是纯**死区**:持锁方一旦崩溃,这段内所有写方必然全部失败。
|
|
190
|
+
// ② 现在取 **5000 / 4000**:死区没了,且**等锁的最坏冻结从 20 秒降到 5 秒**。
|
|
191
|
+
// 依据是实测(本机):纯状态的临界区极短——小文件读-改-写 **0.13ms**,
|
|
192
|
+
// 连最重的 cache-stats 轮转(1.43MB / 2 万行解析 + 重写 1 万行)也只有 **6ms**。
|
|
193
|
+
// 5 秒是实测最坏值的约 800 倍,正常争用绝不会误判超时;**只有**「持有者活着但卡死」
|
|
194
|
+
// 或「锁内容读不出 pid 且超过 staleMs」才会等这么久。
|
|
195
|
+
// ③ 两个值都可用环境变量覆盖,便于诊断与测试(例如调小 staleMs 以便立刻回收陈旧锁)。
|
|
196
|
+
const ENV_TIMEOUT = Number(process.env.MINGDAO_LOCK_TIMEOUT_MS);
|
|
197
|
+
const ENV_STALE = Number(process.env.MINGDAO_LOCK_STALE_MS);
|
|
198
|
+
const DEFAULT_TIMEOUT_MS = Number.isFinite(ENV_TIMEOUT) && ENV_TIMEOUT > 0 ? ENV_TIMEOUT : 5000;
|
|
199
|
+
const DEFAULT_STALE_MS = Number.isFinite(ENV_STALE) && ENV_STALE > 0 ? ENV_STALE : 4000;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 锁超时的统一文案:**说清锁文件在哪、怎么看持有者、什么时候可以删**。
|
|
203
|
+
* 只报一句"获取文件锁超时"的话,用户除了重试什么也做不了。
|
|
204
|
+
* @param {string} lockPath @param {number} timeoutMs
|
|
205
|
+
*/
|
|
206
|
+
function lockTimeoutError(lockPath, timeoutMs) {
|
|
207
|
+
return new Error(
|
|
208
|
+
`获取文件锁超时(${(timeoutMs / 1000).toFixed(1)} 秒,${lockPath}):可能有其他进程长时间占用或已卡死。\n` +
|
|
209
|
+
` 排查:查看该 .lock 文件内容(形如 {"pid":123,"at":…}),确认那个 pid 是否还活着;` +
|
|
210
|
+
`若不是活着的 mingdao 进程,删除该文件后重试即可(陈旧锁也会在 ${DEFAULT_STALE_MS / 1000} 秒后自动回收)。`
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function withFileLockSync(/** @type {string} */ lockPath, /** @type {() => any} */ fn, { timeoutMs = DEFAULT_TIMEOUT_MS, staleMs = DEFAULT_STALE_MS } = {}) {
|
|
215
|
+
return runWithHeld((held) => {
|
|
216
|
+
// 可重入:同一条调用链内已持该锁 → 直接执行,不再二次抢锁
|
|
217
|
+
if (held.has(lockPath)) return fn();
|
|
218
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
219
|
+
const t0 = Date.now();
|
|
220
|
+
for (;;) {
|
|
221
|
+
// v0.4.7:区分「抢锁阶段」与「执行 fn 阶段」。此前 fn 自身抛出的 EEXIST 也会落进下方的
|
|
222
|
+
// 锁重试分支,而 finally 已释放锁 → 重抢成功后再次执行 fn → 再次抛出 → **同步死循环**
|
|
223
|
+
// (100% CPU,连超时分支都不可达)。acquiring 在 fn 开始执行前置 false。
|
|
224
|
+
let acquiring = true;
|
|
66
225
|
try {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
226
|
+
if (!tryAcquire(lockPath)) {
|
|
227
|
+
const e = /** @type {any} */ (new Error('EEXIST'));
|
|
228
|
+
e.code = 'EEXIST';
|
|
229
|
+
throw e;
|
|
230
|
+
}
|
|
231
|
+
held.add(lockPath);
|
|
232
|
+
acquiring = false; // 锁已持有:此后 fn 的任何异常都不得进入锁重试分支
|
|
70
233
|
try {
|
|
71
|
-
|
|
72
|
-
}
|
|
234
|
+
return fn();
|
|
235
|
+
} finally {
|
|
236
|
+
held.delete(lockPath);
|
|
237
|
+
releaseLock(lockPath);
|
|
238
|
+
}
|
|
239
|
+
} catch (err) {
|
|
240
|
+
if (!acquiring) throw err; // fn 自身的异常绝不进入锁重试
|
|
241
|
+
if (/** @type {any} */ (err).code !== 'EEXIST') throw err;
|
|
242
|
+
if (reclaimIfStale(lockPath, staleMs)) continue;
|
|
243
|
+
if (Date.now() - t0 > timeoutMs) {
|
|
244
|
+
throw lockTimeoutError(lockPath, timeoutMs);
|
|
245
|
+
}
|
|
246
|
+
sleepMs(25);
|
|
73
247
|
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 异步版互斥:语义与 withFileLockSync **完全一致**(同一套 tryAcquire / reclaimIfStale /
|
|
254
|
+
* 可重入规则、同样的超时与错误文案),唯一区别是等待时 `await sleepAsync()` —— 让出事件循环,
|
|
255
|
+
* 因此**不会冻结 WebUI**。事件循环敏感的调用点(请求路径)应当用它。
|
|
256
|
+
*
|
|
257
|
+
* 为什么值得单独一个函数而不是把同步版改成异步:调用链里不少地方本身就是同步的
|
|
258
|
+
* (CLI 一次性命令、调度守护进程内部的读-改-写),强行异步化会把 async 传染到 24 个调用点,
|
|
259
|
+
* 回归风险大于收益。做法是**逐个判断**:请求路径上的迁移,纯同步链保留同步版。
|
|
260
|
+
* @param {string} lockPath @param {() => any} fn
|
|
261
|
+
*/
|
|
262
|
+
export async function withFileLock(/** @type {string} */ lockPath, /** @type {() => any} */ fn, { timeoutMs = DEFAULT_TIMEOUT_MS, staleMs = DEFAULT_STALE_MS, pollMs = 25 } = {}) {
|
|
263
|
+
return runWithHeld(async (held) => {
|
|
264
|
+
if (held.has(lockPath)) return fn();
|
|
265
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
266
|
+
const t0 = Date.now();
|
|
267
|
+
for (;;) {
|
|
268
|
+
if (tryAcquire(lockPath)) {
|
|
269
|
+
held.add(lockPath);
|
|
87
270
|
try {
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (reclaimable) {
|
|
95
|
-
// TOCTOU 防护(OfficeACE 报告):unlink 前读锁内容并二次 stat 比对,
|
|
96
|
-
// 防两个进程同时判定陈旧、后者误删前者刚创建的新锁(互斥失效)
|
|
97
|
-
try {
|
|
98
|
-
const before = fs.readFileSync(lockPath, 'utf8');
|
|
99
|
-
const st2 = fs.statSync(lockPath);
|
|
100
|
-
if (st2.mtimeMs === st.mtimeMs && before === fs.readFileSync(lockPath, 'utf8')) {
|
|
101
|
-
fs.unlinkSync(lockPath);
|
|
102
|
-
}
|
|
103
|
-
} catch {}
|
|
104
|
-
continue; // 无论是否回收成功都重试一次(若锁刚被他人更新则继续等待)
|
|
271
|
+
return await fn();
|
|
272
|
+
} finally {
|
|
273
|
+
// 注意:这里必须放在 try/finally 里而不是靠外层 catch——
|
|
274
|
+
// fn 的异常要原样抛出,绝不能被误当成"抢锁失败"而重跑临界区
|
|
275
|
+
held.delete(lockPath);
|
|
276
|
+
releaseLock(lockPath);
|
|
105
277
|
}
|
|
106
|
-
} catch {
|
|
107
|
-
continue; // 锁文件刚被释放
|
|
108
278
|
}
|
|
279
|
+
if (reclaimIfStale(lockPath, staleMs)) continue;
|
|
109
280
|
if (Date.now() - t0 > timeoutMs) {
|
|
110
|
-
throw
|
|
281
|
+
throw lockTimeoutError(lockPath, timeoutMs);
|
|
111
282
|
}
|
|
112
|
-
|
|
283
|
+
await sleepAsync(pollMs);
|
|
113
284
|
}
|
|
114
|
-
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 同步/异步锁的默认超时与陈旧阈值(导出供诊断与测试断言「上限有界」)。 */
|
|
289
|
+
export function lockDefaults() {
|
|
290
|
+
return { timeoutMs: DEFAULT_TIMEOUT_MS, staleMs: DEFAULT_STALE_MS };
|
|
115
291
|
}
|
package/src/audit.js
CHANGED
|
@@ -8,7 +8,7 @@ import fs from 'node:fs';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { mingdaoHome, ensureHome } from './config.js';
|
|
10
10
|
import { redactSecrets } from './redact.js';
|
|
11
|
-
import { atomicWriteFileSync } from './atomic-write.js';
|
|
11
|
+
import { atomicWriteFileSync, withFileLockSync } from './atomic-write.js';
|
|
12
12
|
|
|
13
13
|
// v0.6.2(第三方代码审计 P2-2):截断触发改为看**文件大小**。
|
|
14
14
|
// 原判据 `auditCount > 20000` 是**进程内**计数:CLI 每次会话只 append 几次、进程结束即归零,
|
|
@@ -40,10 +40,28 @@ export function writeAudit(/** @type {any} */ entry) {
|
|
|
40
40
|
try {
|
|
41
41
|
ensureHome();
|
|
42
42
|
const file = auditFile();
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
// v0.6.3(M-20):**追加与轮转必须在同一把锁里**。原实现是「锁外追加 + 锁外读改写轮转」,
|
|
44
|
+
// 丢失窗口很实在:A 追加 → B 追加 → A 读到含 A 的快照 → A 原子写回 → B 那一行消失。
|
|
45
|
+
// 审计是合规证据,"少一行"等于证据链有洞,而且不报错。锁的范围含 append:代价是每次
|
|
46
|
+
// 工具调用多一次毫秒级文件锁(审计本就每工具调用一条,不在热路径上)。
|
|
47
|
+
let appended = false;
|
|
48
|
+
withFileLockSync(file + '.lock', () => {
|
|
49
|
+
fs.appendFileSync(file, JSON.stringify(entry) + '\n');
|
|
50
|
+
appended = true;
|
|
51
|
+
try {
|
|
52
|
+
fs.chmodSync(file, 0o600);
|
|
53
|
+
} catch {}
|
|
54
|
+
// 低频截断:statSync 廉价,只有真的超过阈值才整文件读一次并重写
|
|
55
|
+
if (fs.statSync(file).size > MAX_BYTES) {
|
|
56
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
57
|
+
if (lines.length > KEEP_LINES) {
|
|
58
|
+
// v0.6.2(P2-3):**原子写**——原先直接 writeFileSync,截断过程中崩溃会留下半截
|
|
59
|
+
// audit.jsonl(审计证据丢事件)。atomicWriteFileSync 的 tmp 名含 pid+随机后缀,
|
|
60
|
+
// 写完 rename 原子替换,读者永远看到完整文件;mode 保持 0600。
|
|
61
|
+
atomicWriteFileSync(file, lines.slice(-KEEP_LINES).join('\n') + '\n', { mode: 0o600 });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
47
65
|
|
|
48
66
|
} catch (err) {
|
|
49
67
|
// v0.6.2(B-WS-1/2 第八处):仍然「不影响会话」(审计不能反过来打断用户干活),
|
|
@@ -60,19 +78,6 @@ export function writeAudit(/** @type {any} */ entry) {
|
|
|
60
78
|
}
|
|
61
79
|
return;
|
|
62
80
|
}
|
|
63
|
-
// 低频截断:statSync 廉价,只有真的超过阈值才整文件读一次并重写
|
|
64
|
-
try {
|
|
65
|
-
const f = auditFile(); // 上面 try 里的 file 是块内作用域,这里单独取一次
|
|
66
|
-
if (fs.statSync(f).size > MAX_BYTES) {
|
|
67
|
-
const lines = fs.readFileSync(f, 'utf8').split('\n').filter(Boolean);
|
|
68
|
-
if (lines.length > KEEP_LINES) {
|
|
69
|
-
// v0.6.2(P2-3):**原子写**——原先直接 writeFileSync,截断过程中崩溃会留下半截
|
|
70
|
-
// audit.jsonl(审计证据丢事件)。atomicWriteFileSync 的 tmp 名含 pid+随机后缀,
|
|
71
|
-
// 写完 rename 原子替换,读者永远看到完整文件;mode 保持 0600。
|
|
72
|
-
atomicWriteFileSync(f, lines.slice(-KEEP_LINES).join('\n') + '\n', { mode: 0o600 });
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
} catch {}
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
export function listAudit(limit = 20) {
|
package/src/cachestats.js
CHANGED
|
@@ -5,7 +5,7 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { mingdaoHome, ensureHome } from './config.js';
|
|
7
7
|
import { estimateCost, cacheSplit, beijingDayStart, beijingParts } from './pricing.js';
|
|
8
|
-
import { withFileLockSync, atomicWriteFileSync } from './atomic-write.js';
|
|
8
|
+
import { withFileLockSync, atomicWriteFileSync, appendFilePrivateSync } from './atomic-write.js';
|
|
9
9
|
|
|
10
10
|
export function cacheStatsFile() {
|
|
11
11
|
return path.join(mingdaoHome(), 'cache-stats.jsonl');
|
|
@@ -69,63 +69,73 @@ export function recordCacheStats(/** @type {any} */ entry) {
|
|
|
69
69
|
aux: entry.aux === true ? true : undefined,
|
|
70
70
|
auxReason: entry.auxReason ? String(entry.auxReason) : undefined,
|
|
71
71
|
});
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
writeWarned = true;
|
|
79
|
-
console.warn(
|
|
80
|
-
`[MingDao] ⚠ 费用明细写入失败:${msg}\n` +
|
|
81
|
-
` 今日费用护栏会**少计**这部分消费(可能超支而不知);请检查 ${cacheStatsFile()} 的磁盘空间与权限。`
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
// 追加都失败了,后面的轮转毫无意义(statSync 也大概率抛错),直接返回真实原因,
|
|
85
|
-
// 不要让轮转分支的异常把 append 的原因覆盖掉
|
|
86
|
-
return { ok: false, error: msg, phase: 'append' };
|
|
87
|
-
}
|
|
88
|
-
try {
|
|
89
|
-
if (fs.statSync(cacheStatsFile()).size > MAX_BYTES) {
|
|
90
|
-
// 审计 P2-3(v0.4.2):轮转 read-modify-write 加跨进程锁——web/CLI/worker 多进程并发轮转时,
|
|
91
|
-
// 读与写之间他人追加的行会被覆写丢失;锁内重读再瘦身,写用原子替换。
|
|
72
|
+
// v0.6.3(BUG-009):**追加与轮转共用同一把锁**。
|
|
73
|
+
// 原实现是「锁外追加 + 锁内轮转」,仍留有丢失窗口:A 追加 → B 追加 → A 进锁读整文件、
|
|
74
|
+
// 算出瘦身结果、原子替换 —— B 那一行若落在 A 的「读」与「写」之间,就被整文件替换覆盖掉。
|
|
75
|
+
// 丢的是费用明细,而费用明细正是日费用护栏的依据(少计 → 超支而不自知)。
|
|
76
|
+
let appended = false;
|
|
77
|
+
try {
|
|
92
78
|
withFileLockSync(cacheStatsFile() + '.lock', () => {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (
|
|
96
|
-
// v0.4.
|
|
79
|
+
appendFilePrivateSync(cacheStatsFile(), line + '\n');
|
|
80
|
+
appended = true;
|
|
81
|
+
if (fs.statSync(cacheStatsFile()).size > MAX_BYTES) {
|
|
82
|
+
// 审计 P2-3(v0.4.2):轮转的 read-modify-write 同样有并发窗口,现在它与追加在同一锁内。
|
|
83
|
+
// v0.4.6 P2:轮转必须保留**当天全部**记录。此前只保留最后 KEEP_LINES 行——
|
|
97
84
|
// 单进程写满 2 万条后,当天早先的费用会被裁掉,todayCost() 随之变小,
|
|
98
85
|
// 日费用护栏被静默重置(用户可再次超支而不自知)。当天行(最多 MAX_LINES 条)
|
|
99
86
|
// 与最近 KEEP_LINES 行取并集,去重且保持原顺序。
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
87
|
+
const raw = fs.readFileSync(cacheStatsFile(), 'utf8');
|
|
88
|
+
const rows = raw.split('\n').filter(Boolean);
|
|
89
|
+
if (rows.length > MAX_LINES) {
|
|
90
|
+
const dayStart = beijingDayStart().getTime();
|
|
91
|
+
const todayLines = rows.filter((/** @type {string} */ l) => {
|
|
92
|
+
try {
|
|
93
|
+
return (JSON.parse(l).at || 0) >= dayStart;
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
const tail = rows.slice(-KEEP_LINES);
|
|
99
|
+
const tailSet = new Set(tail);
|
|
100
|
+
const todayCapped = todayLines.length > MAX_LINES ? todayLines.slice(-MAX_LINES) : todayLines;
|
|
101
|
+
const todaySet = new Set(todayCapped);
|
|
102
|
+
const merged = [...todayCapped, ...tail.filter((l) => !todaySet.has(l))];
|
|
103
|
+
// 顺序按原文件恢复(避免打乱 byDay 折线的时间序)
|
|
104
|
+
const order = new Map(rows.map((l, k) => [l, k]));
|
|
105
|
+
merged.sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0));
|
|
106
|
+
atomicWriteFileSync(cacheStatsFile(), merged.join('\n') + '\n');
|
|
107
|
+
}
|
|
117
108
|
}
|
|
118
109
|
});
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const msg = String(/** @type {any} */ (err)?.message ?? err);
|
|
112
|
+
lastWriteError = msg;
|
|
113
|
+
if (!appended) {
|
|
114
|
+
// 只提示一次:写不进去通常是持续性问题(磁盘满/权限),每回合刷屏反而让人忽略它
|
|
115
|
+
if (!writeWarned) {
|
|
116
|
+
writeWarned = true;
|
|
117
|
+
console.warn(
|
|
118
|
+
`[MingDao] ⚠ 费用明细写入失败:${msg}\n` +
|
|
119
|
+
` 今日费用护栏会**少计**这部分消费(可能超支而不知);请检查 ${cacheStatsFile()} 的磁盘空间与权限。`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return { ok: false, error: msg, phase: 'append' };
|
|
123
|
+
}
|
|
124
|
+
// 追加已成功、只有轮转失败:丢的不是账,而是文件会持续增长
|
|
125
|
+
if (!rotateWarned) {
|
|
126
|
+
rotateWarned = true;
|
|
127
|
+
console.warn(`[MingDao] ⚠ 费用明细轮转失败:${msg}\n ${cacheStatsFile()} 会持续增长(不影响计费,但请检查磁盘空间/权限)。`);
|
|
128
|
+
}
|
|
129
|
+
return { ok: false, error: msg, phase: 'rotate' };
|
|
119
130
|
}
|
|
120
131
|
} catch (err) {
|
|
121
|
-
// 轮转失败与追加失败是两件事:这里丢的不是账,而是文件会持续增长
|
|
122
132
|
const msg = String(/** @type {any} */ (err)?.message ?? err);
|
|
123
133
|
lastWriteError = msg;
|
|
124
|
-
if (!
|
|
125
|
-
|
|
126
|
-
console.warn(`[MingDao] ⚠
|
|
134
|
+
if (!writeWarned) {
|
|
135
|
+
writeWarned = true;
|
|
136
|
+
console.warn(`[MingDao] ⚠ 费用明细写入失败:${msg}\n 今日费用护栏会**少计**这部分消费(可能超支而不知);请检查 ${cacheStatsFile()} 的磁盘空间与权限。`);
|
|
127
137
|
}
|
|
128
|
-
return { ok: false, error: msg, phase: '
|
|
138
|
+
return { ok: false, error: msg, phase: 'append' };
|
|
129
139
|
}
|
|
130
140
|
return { ok: true, error: null, phase: null };
|
|
131
141
|
}
|
package/src/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import fs from 'node:fs';
|
|
|
7
7
|
import { DEFAULT_MODEL } from './models.js';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import readline from 'node:readline';
|
|
10
|
-
import { loadConfig, saveConfig, runWizard, ensureHome, mingdaoHome } from './config.js';
|
|
10
|
+
import { loadConfig, saveConfig, runWizard, ensureHome, mingdaoHome, readConfigStrict, quarantineCorruptConfig } from './config.js';
|
|
11
11
|
import { helpLines } from './help.js';
|
|
12
12
|
import { modelPreset, PROVIDERS } from './models.js';
|
|
13
13
|
import { maskKey, getStoredKey } from './credentials.js';
|
|
@@ -231,7 +231,7 @@ async function main() {
|
|
|
231
231
|
if (opts.prompt[0] === 'schedule-daemon') {
|
|
232
232
|
const home0 = ensureHome();
|
|
233
233
|
const { listSchedules, runSleeper, sleeperAlive, procAlive, daemonPidFile, writeSchedule } = await import('./schedule.js');
|
|
234
|
-
const { readTask } = await import('./tasks.js');
|
|
234
|
+
const { readTask, taskWorkerAlive } = await import('./tasks.js');
|
|
235
235
|
const handled = new Set();
|
|
236
236
|
const supervising = new Set(); // 本 daemon 正在监督的任务(防崩溃恢复误判正在执行的任务)
|
|
237
237
|
const nonce = String(opts.prompt[1] || '');
|
|
@@ -261,7 +261,16 @@ async function main() {
|
|
|
261
261
|
// v0.4.7(P2 T15):先看「跑这个任务的宿主进程」是否仍存活。存活说明它正在跑
|
|
262
262
|
// (可能还没写 lastTaskId),**等它**——否则「本 daemon 刚接管 + 旧 daemon 在途」
|
|
263
263
|
// 会被当成崩溃残留 → 重置 pending → 并发重跑同一任务。
|
|
264
|
-
|
|
264
|
+
//
|
|
265
|
+
// v0.6.3(P1-5):但「宿主就是自己」时必须例外——markRunning 写的 runnerPid 就是
|
|
266
|
+
// 本 daemon 的 pid,于是 `procAlive(自己)` 恒真 → `continue` → 这条 job **永久跳过**:
|
|
267
|
+
// 面板一直转圈、既不重跑也不收尾,用户只能 daemon stop + 删 pidfile 自愈。
|
|
268
|
+
// 同理:宿主只是"标记",真正在跑的是 detached worker,绝不能凭宿主死活就重排。
|
|
269
|
+
const runnerIsSelf = Number(j.runnerPid) === process.pid;
|
|
270
|
+
if (!runnerIsSelf && procAlive(j.runnerPid)) continue;
|
|
271
|
+
// worker 仍活着(宿主被 SIGKILL 但 detached worker 还在跑)→ **绝不重排**,
|
|
272
|
+
// 否则同一个任务会跑第二遍;等它收尾,下一轮恢复会走「t.status !== running」的定案分支。
|
|
273
|
+
if (t && t.status === 'running' && taskWorkerAlive(t)) continue;
|
|
265
274
|
if (!t) {
|
|
266
275
|
await writeSchedule(home0, { ...j, status: 'pending' });
|
|
267
276
|
continue;
|
|
@@ -292,7 +301,14 @@ async function main() {
|
|
|
292
301
|
handled.add(j.id);
|
|
293
302
|
supervising.add(j.id);
|
|
294
303
|
runSleeper(home0, j.id, { shouldStop: () => leaseLost })
|
|
295
|
-
.
|
|
304
|
+
// v0.6.3(P1-5):不能静默吞。协程一次异常(锁超时/写盘失败)此前**无痕**消失,
|
|
305
|
+
// 于是 job 停在 running、下一轮恢复又因 runnerPid 指向自己而跳过 → 永久卡住。
|
|
306
|
+
// 现在至少留一条可诊断的日志(用户能在 daemon 日志里看到"为什么没跑")。
|
|
307
|
+
.catch((/** @type {any} */ e) => {
|
|
308
|
+
try {
|
|
309
|
+
console.error(`[MingDao] ⚠ 调度协程异常(任务 ${j.id}):${e?.message || e}`);
|
|
310
|
+
} catch {}
|
|
311
|
+
})
|
|
296
312
|
.finally(() => {
|
|
297
313
|
handled.delete(j.id);
|
|
298
314
|
supervising.delete(j.id);
|
|
@@ -341,6 +357,11 @@ async function main() {
|
|
|
341
357
|
return;
|
|
342
358
|
}
|
|
343
359
|
const home = ensureHome();
|
|
360
|
+
// v0.6.3(H-7):进向导**之前**先分清「没有配置」与「配置读不出来」。
|
|
361
|
+
// 后者若被当成首次运行,向导会用全新对象整文件覆盖掉用户配置(customModels/mcpServers/
|
|
362
|
+
// sync/net/costGuard 全丢)且不备份。这里先把损坏文件改名备份并告警,再继续走首次向导。
|
|
363
|
+
const cfgStrict = readConfigStrict();
|
|
364
|
+
if (cfgStrict.exists && !cfgStrict.ok) quarantineCorruptConfig(cfgStrict.error || '未知原因');
|
|
344
365
|
let cfg = loadConfig();
|
|
345
366
|
if (!cfg || opts.init) {
|
|
346
367
|
const wio = createIO();
|
package/src/commands/diagnose.js
CHANGED
|
@@ -112,6 +112,9 @@ export async function handleDiagnose(/** @type {any} */ _cmd, /** @type {any} */
|
|
|
112
112
|
io.print(style('请把该文件内容贴到反馈渠道排查;密钥/token/私网路径已脱敏。', C.dim));
|
|
113
113
|
} catch (/** @type {any} */ err) {
|
|
114
114
|
io.print(style('[错误] ' + (err?.message || err), C.red));
|
|
115
|
+
// v0.6.3(M-21):诊断包生成失败必须体现在退出码上——脚本化收集诊断信息时
|
|
116
|
+
// 「打印了错误但退 0」会让调用方以为拿到了报告。
|
|
117
|
+
process.exitCode = 1;
|
|
115
118
|
} finally {
|
|
116
119
|
io.close();
|
|
117
120
|
}
|