mingdao-harness 0.6.30 → 0.6.32

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/CONFIG.md CHANGED
@@ -995,6 +995,30 @@ export async function createProvider(cfg) {
995
995
  - 调度器为**单守护进程**:`mingdao schedule daemon [status|stop]` 查看/停止;有任务时任意
996
996
  schedule 命令自动拉起,无任务自动退出(每任务一个 sleeper 进程的旧方案仅作兜底)。
997
997
 
998
+ ## 持久状态文件的锁与原子写(v0.7.0 §1.3)
999
+
1000
+ `~/.mingdao` 下的六类状态文件会被**多个进程同时写**(WebUI 服务、CLI、同步命令、守护进程、
1001
+ 并发会话)。单进程顺序调用看不出问题,但两个进程各拿一份旧快照做「读 → 改 → 写」时,后写者会
1002
+ 把前者刚写的字段整份覆盖——用户看到的是「我明明填了 Key/改了模型/学到的预算,过一会自己变回去了」。
1003
+ v0.7.0 起这六处统一走 `src/atomic-write.js` 的既有原语:`withFileLock`(异步,请求路径)/
1004
+ `withFileLockSync`(CLI)/ `atomicWriteFileSync`(tmp + fsync + rename + 目录 fsync)。
1005
+
1006
+ | 文件 | 锁 | 写入语义 |
1007
+ | --- | --- | --- |
1008
+ | `config.json` | `config.json.lock` | 锁内**重读磁盘**,只合并本次相对读入快照真正改变的字段;删除字段仍生效。`saveConfig(cfg)` 签名不变,返回 `{ok,error}`;WebUI 用 `saveConfigAsync` |
1009
+ | `credentials.json` | `credentials.json.lock` | 锁内重读;`setStoredKey` 只改一把 Key。并发设两把不同 provider 的 Key 都会保留 |
1010
+ | `model-cache.json` | `model-cache.json.lock` | 唯一读写入口 `readModelCache()` / `writeModelCache()`(`src/config.js`);锁内重读再改,原子替换。写中崩溃只会留下旧版本或临时文件,**不会**留下半截 JSON |
1011
+ | `endpoint-limits.json` | `endpoint-limits.json.lock` | 锁内重读整表、只更新本 `baseUrl` 那一条,再原子写;并发学习不同端点不会整表覆盖 |
1012
+ | `ledger/<run>.jsonl` | `<run>.jsonl.lock` | 追加一行在锁内完成;哈希链 `prev = digestOf(本行)` 语义不变 |
1013
+ | `net.jsonl`(出网日志) | `net.jsonl.lock` | 追加与轮转(按大小、原子替换)在**同一把锁**内完成;不再有无锁整文件重写 |
1014
+
1015
+ **写失败必须可见**:以上写路径失败时都会 `console.warn` 一条 `[MingDao] ⚠ …失败…`,并返回
1016
+ `{ok:false,error}`(账本/出网日志降级不中断执行)。不存在「静默成功」或「静默吞掉」。
1017
+
1018
+ **并发证据**:`node test/state-locks.js`(16 路真子进程写 config、并发设 Key、写中 `kill -9`、
1019
+ 并发学习端点、并发账本/出网日志、注入 `EACCES` 的告警断言);变异验证:
1020
+ `node test/mutate/batch69-state-locks.mjs`。
1021
+
998
1022
  ## 账本来源签名(v0.6.11:`mingdao ledger --sign-key`)
999
1023
 
1000
1024
  哈希链 + 封条证明的是「这份账本自写入后没被改动、尾部没被截断」,但它们的全部输入都来自
package/docs/DEVELOPER.md CHANGED
@@ -227,3 +227,94 @@ console.log(res.text, res.usage, res.perf);
227
227
  ```bash
228
228
  node scripts/ui-shot.mjs --viewport narrow --theme light --out /tmp/mdh-shots-narrow-light
229
229
  ```
230
+
231
+ ## 七、覆盖率口径与棘轮(v0.7.0)
232
+
233
+ > 背景:v0.6.31 及以前,`scripts/coverage-report.mjs` 把每个文件所有 `count>0` 的 V8 区间
234
+ > **取并集**来判"这一行覆盖了没有"。V8 的区间是**嵌套**的,而模块外壳(顶层函数体)区间横跨
235
+ > 全文——只要文件被 import 过,外壳区间就覆盖整份文件,未执行的分支与从未调用的函数体统统被
236
+ > 算成"已覆盖"。后果:仓库自报 88.7%,真实 code-lines 只有 63.9%(差 18.2pp),"覆盖率最低
237
+ > 的 8 个文件"全部显示 95–99%。尺子坏了,"绿"就不可信。
238
+
239
+ ### 7.1 三种口径
240
+
241
+ | 口径 | 分母 | 分子 |
242
+ | --- | --- | --- |
243
+ | `code-lines`(主指标 / 门禁) | 排除空行与纯注释行后的代码行 | 有执行的代码行 |
244
+ | `all-lines`(保守下界) | 文件全部行(含空行 / 纯注释行) | 同上 |
245
+ | `functions` | 已加载文件里 V8 `functions` 数组的函数(**不含模块外壳**) | `ranges[0].count > 0`(函数被调用过) |
246
+
247
+ - 空行 / 纯注释行不可能被执行,所以两个行口径的**分子相同**,差别只在分母:
248
+ `all-lines ≤ code-lines` 恒成立。别把 `all-lines` 当主指标(它会被注释多的文件稀释)。
249
+ - `functions` 里排除的是 V8 为模块包的那层函数体(`functionName === ''` 且区间横跨全文),
250
+ 它不是业务函数。未加载文件在 `functions` 口径里没有函数数据(0/0),其代价由行口径承担。
251
+ - 判定算法(`computeLineCoverage`):对每行的**第一个非空白代码字符**取包含它的**最内层(叶子)
252
+ 区间**,该区间 `count > 0` 才算覆盖。父区间**不代表**子行覆盖——未执行的分支 / 函数体本身
253
+ 就是一条 `count=0` 的更内层区间。
254
+ - 纯注释 / 空行的判定是**简单词法启发式**(项目此前没有实现):逐字符扫描,跳过 `//`、`/* */`
255
+ (可跨行)与字符串 / 模板字面量。**不解析正则字面量**——`/["']/` 这类写法可能让扫描器误进
256
+ 字符串状态,只影响"哪一行算代码行",不影响区间计数;要精确就得引入真正的解析器。
257
+ - 已知保守点:函数声明行(`function foo() {` 与函数体同处一行)在 V8 里属于该函数自己的区间,
258
+ 函数从未调用时这一行按未覆盖计。它只会让数字偏低,不会把没执行的代码算成绿色。
259
+ - **与 v0.6.31 自审数字的关系(避免误读)**:旧"父区间并集"口径的 all-lines 88.7% 可以逐位复现
260
+ (31794/35846 行);审计文里的"真实 70.5%(全行)/ 63.9%(仅代码行)"用的是另一套行锚点与分母
261
+ (注释行按外层区间算覆盖、分母不含空行;行尾锚点),**同一份数据**按那套口径实测是
262
+ 71.1% / 64.3%。本实现选"行首代码字符 + 空行/注释行恒未覆盖",所以 all-lines 更低
263
+ (50.1%)、code-lines 略高(68.4%)。差别只在"同一行的哪个偏移代表这一行",不是
264
+ "叶子区间"原则本身——套件钉住的是原则(父区间不得覆盖子行),与锚点选择无关。
265
+
266
+ ### 7.2 为什么不许用"父区间并集"
267
+
268
+ V8 块覆盖率(`isBlockCoverage`)的语义是:一条区间的计数只作用于**没有被更内层区间覆盖的部分**。
269
+ 把 `count>0` 的区间取并集,等于宣布"父区间执行过 ⇒ 它里面的每一行都执行过",与 V8 的语义正相反;
270
+ 模块外壳又恰好横跨全文,于是缺陷被放大成"import 过 = 全覆盖"。它让未执行的分支、从未调用的
271
+ 函数体在报告里变成绿色——覆盖率门禁因此失去意义,而不是"数字难看"的问题。
272
+
273
+ 正确做法就是 7.1 的"最内层区间":区间天然嵌套(V8 保证父子关系),用"外层先入栈、行推进时
274
+ 弹出已结束区间、栈顶即叶子"的单遍扫描即可。`test/coverage-metric.js` 用手写 V8 片段把这条
275
+ 算法钉住(嵌套 / 兄弟 / 整文件函数 / 未执行函数 / 纯注释 / 空文件 / 多进程合并)。
276
+
277
+ ### 7.3 基线(ratchet)
278
+
279
+ `scripts/coverage-baseline.json` 记录三口径真实基线(含 `generatedAt` 与 `node` 版本):
280
+
281
+ ```json
282
+ {
283
+ "version": 1,
284
+ "generatedAt": "<ISO 时间>",
285
+ "node": "v24.x",
286
+ "platform": "darwin arm64",
287
+ "srcFiles": 90,
288
+ "note": "过渡基线;发布前重生成",
289
+ "metrics": {
290
+ "code-lines": { "covered": 0, "total": 0, "pct": 0 },
291
+ "all-lines": { "covered": 0, "total": 0, "pct": 0 },
292
+ "functions": { "covered": 0, "total": 0, "pct": 0 }
293
+ }
294
+ }
295
+ ```
296
+
297
+ - `npm run coverage`(= `node test/run-all.mjs --coverage && node scripts/coverage-report.mjs`)
298
+ 逐项比较,**任一指标低于基线即 exit 1**(相等通过,只许升不许降)。
299
+ - 比较带 **-0.2pp 容差**(`RATCHET_TOLERANCE_PP`):只吸收 `63.94 → 63.9` 这类四舍五入与
300
+ 同一次提交内的微小噪声;真正的回退(≥0.3pp)照样红。容差**不是**"允许慢慢降"的配额。
301
+ - 首行仍是旧格式(`行覆盖率(分母 = src 全部 N 个文件):X%(h/l 行)· 阈值 T%`),
302
+ 其中 X 现在是 code-lines 的真实值,阈值默认取基线的 code-lines;可用
303
+ `MINGDAO_COVERAGE_THRESHOLD=70` 覆盖成绝对下限(例如临时收紧门禁)。
304
+ - 要抬升基线(补了测试 / 确认口径变化)时**显式**运行 `npm run coverage:baseline`
305
+ (= `node scripts/coverage-report.mjs --update-baseline`):它把三口径、生成时间与 node 版本
306
+ 写回基线文件,review 里能看到这次"抬升"是刻意做的。**规矩:抬基线必须与"让覆盖率变好/口径
307
+ 变化"的改动在同一次提交里完成**,不允许单独提交一个"把门槛调低"的基线 diff。
308
+ 已有的 `note` 字段会被保留,要改说明就加 `--note "..."`。**不要**为了变绿去改算法或手工改数字。
309
+ - 指定数据目录:`node scripts/coverage-report.mjs --dir /tmp/cov`(或 `MINGDAO_COVERAGE_DIR=/tmp/cov`)。
310
+ 本地完整复现:
311
+
312
+ ```bash
313
+ rm -rf /tmp/cov && NODE_V8_COVERAGE=/tmp/cov node test/run-all.mjs
314
+ node scripts/coverage-report.mjs --dir /tmp/cov
315
+ ```
316
+
317
+ - 报告里"覆盖率最低的 8 个文件"现在按 **code-lines 升序**取真正的低覆盖文件(旧实现按行数
318
+ 降序取末 8 个 = 最小的 8 个文件,所以那行标题下的名单从来没和"最低覆盖"对应过)。
319
+ - 基线是在本机 Node v24 / macOS 上实测生成的;CI 覆盖率腿跑 Linux + Node 20。V8 对同一份源码
320
+ 的块区间理论上一致,若 CI 实测略有出入,应按 CI 的实测值重建基线并在提交说明里写明原因。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.30",
3
+ "version": "0.6.32",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -68,6 +68,8 @@
68
68
  "desktop:sync": "node scripts/sync-versions.mjs",
69
69
  "bench": "node test/bench/bench-tokenizer.mjs && node test/bench/bench-routing.mjs && node test/bench/bench-compaction.mjs && node test/bench/bench-cost.mjs && node test/bench/bench-savings.mjs",
70
70
  "coverage": "node test/run-all.mjs --coverage && node scripts/coverage-report.mjs",
71
+ "coverage:report": "node scripts/coverage-report.mjs",
72
+ "coverage:baseline": "node scripts/coverage-report.mjs --update-baseline",
71
73
  "typecheck:strict": "node scripts/strict-ratchet.mjs",
72
74
  "preversion": "node test/smoke.js && node test/e2e-local.js && npm run bench",
73
75
  "version": "node scripts/bump-changelog.mjs",
package/src/agent.js CHANGED
@@ -15,7 +15,7 @@ import { makeTokenCounter } from './tokenizer.js';
15
15
  import { createHooks } from './hooks.js';
16
16
  import { createIO, style, C } from './ui.js';
17
17
  import { subagentModel } from './routing.js';
18
- import { writeAudit } from './audit.js';
18
+ import { writeAuditAsync } from './audit.js';
19
19
  import { redactSecrets } from './redact.js';
20
20
  import { checkCostGuard, costGuardConfig, todayCost, preflightBlockMessage, roundGuardAction } from './cost-guard.js';
21
21
  import { recordCacheStats, packDailyCost } from './cachestats.js';
@@ -79,6 +79,56 @@ const SUBAGENT_MAX_STEPS = 24;
79
79
  * · 真干活的回合每隔几秒就有工具结果或正文增量,60 分钟零增量在任何正常回合都不可能。
80
80
  *
81
81
  * 可配置:config.noProgressTimeoutMs(0/负数/NaN/Infinity 一律回落默认——**不能**用它关掉看门狗)。 */
82
+ // v0.6.31(负责人实测):TTFT 提示"每个回合各打一次"在长审计里刷屏。
83
+ //
84
+ // 现象:本地模型跑长审计时,聊天区反复出现「⏱ 首帧 10.9s(第 1 次模型请求…)」十几次,
85
+ // 而且**每次都写"第 1 次"**——因为每个「回合」都是新 turnProgress(用户点「继续」/自动续跑
86
+ // 都算新回合),`llmCalls===1` 这个条件对每个回合都成立。
87
+ //
88
+ // 判定抽成**纯函数**(可单测、可变异),状态放**进程级**(WebUI 每请求新建 agent,只有进程级
89
+ // 才能跨回合节流;键用会话名/工作目录)。
90
+ const TTFT_NOTICE_DEFAULT_QUIET_MS = 10 * 60 * 1000;
91
+ /** @type {Map<string, {ms: number|null, at: number}>} */
92
+ const ttftNoticeState = new Map();
93
+
94
+ /**
95
+ * 本次是否该打印 TTFT 提示。
96
+ * 规则:① 每回合只在第 1 次请求时考虑;② 同一会话 quietMs 内不重复;
97
+ * ③ 首帧比上次打印时**明显变慢**(≥ slowdownRatio 倍)则例外——劣化信号不能被节流吃掉。
98
+ * @param {{llmCalls:number, ttftMs:number, lastMs?:number|null, lastAt?:number, now:number,
99
+ * quietMs?:number, slowdownRatio?:number}} a
100
+ * @returns {{print: boolean, slowdown: boolean}}
101
+ */
102
+ export function shouldPrintTtftNotice({ llmCalls, ttftMs, lastMs = null, lastAt = 0, now, quietMs = TTFT_NOTICE_DEFAULT_QUIET_MS, slowdownRatio = 2 }) {
103
+ if (Number(llmCalls) !== 1) return { print: false, slowdown: false };
104
+ const ms = Number(ttftMs);
105
+ if (!Number.isFinite(ms)) return { print: false, slowdown: false };
106
+ const slowdown = Number(lastMs) > 0 && ms >= Number(lastMs) * Number(slowdownRatio);
107
+ if (slowdown) return { print: true, slowdown: true };
108
+ if (!Number(lastAt)) return { print: true, slowdown: false };
109
+ return { print: now - Number(lastAt) >= Number(quietMs), slowdown: false };
110
+ }
111
+
112
+ /**
113
+ * 读/写某会话的 TTFT 提示节流状态(进程级,上限 200 个键防泄漏)。
114
+ * @param {string} key
115
+ * @returns {{ms: number|null, at: number}}
116
+ */
117
+ export function ttftNoticeStateFor(key) {
118
+ const k = String(key || 'default');
119
+ let v = ttftNoticeState.get(k);
120
+ if (!v) {
121
+ v = { ms: null, at: 0 };
122
+ if (ttftNoticeState.size >= 200) {
123
+ // TS7 下 `MapIterator#value` 是 `string | undefined`:先收窄再删(与原来等价,size>=200 时必有键)。
124
+ const oldest = ttftNoticeState.keys().next().value;
125
+ if (oldest !== undefined) ttftNoticeState.delete(oldest);
126
+ }
127
+ ttftNoticeState.set(k, v);
128
+ }
129
+ return v;
130
+ }
131
+
82
132
  export const DEFAULT_NO_PROGRESS_TIMEOUT_MS = 3600000;
83
133
 
84
134
  /** 父回合在等待 `task` 子代理时额外给的一点点宽限(ms)。
@@ -592,7 +642,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
592
642
  Array.isArray(rawConstraints) ? rawConstraints : cfg?.constraints ?? getActivePackContext()?.constraints
593
643
  );
594
644
  /** 约束事件写审计(pack/constraint/kind/stage/action)——受监管场景要能回答「红线何时被触发」 */
595
- const auditConstraint = (/** @type {any} */ ev) => {
645
+ // v0.7.0 §1.4:约束事件也在 Web 请求路径上(工具执行/输出前检查),审计写必须异步,
646
+ // 否则外部进程持 audit.jsonl.lock 时会冻住整个事件循环。同步版 writeAudit 保留给 CLI 调用链。
647
+ const auditConstraint = async (/** @type {any} */ ev) => {
596
648
  // v0.6.0 C1:账本与 audit 在此**同一处**记录——三个时机(pre/post/output)都汇聚到这里,
597
649
  // 新增时机时不需要记得再补一处账本埋点。
598
650
  // 只记「哪条约束、什么时机、如何处理」,**不记命中的原文**(否则账本自身成为泄露渠道)。
@@ -601,7 +653,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
601
653
  }
602
654
  if (cfg.audit === false || !ev) return;
603
655
  try {
604
- writeAudit({ at: Date.now(), session: sessionRef?.name ?? null, model: modelName, tool: null, constraint: ev });
656
+ await writeAuditAsync({ at: Date.now(), session: sessionRef?.name ?? null, model: modelName, tool: null, constraint: ev });
605
657
  } catch {}
606
658
  };
607
659
  const todos = /** @type {any[]} */ ([]);
@@ -1217,7 +1269,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1217
1269
  if (!constraints.active || !text) return { text, note: null };
1218
1270
  const hit = checkOutput(constraints, text);
1219
1271
  if (!hit) return { text, note: null };
1220
- auditConstraint(hit.event);
1272
+ await auditConstraint(hit.event);
1221
1273
  const cid = hit.constraint?.id || '领域红线';
1222
1274
  if (hit.action === 'warn') {
1223
1275
  try { io.print(style(`⚠ 领域约束「${cid}」提示:${hit.reason}`, C.yellow)); } catch {}
@@ -1249,7 +1301,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1249
1301
  if (fixed) {
1250
1302
  const re = checkOutput(constraints, fixed);
1251
1303
  if (!re) return { text: fixed, note: `⛔ 领域约束「${cid}」命中,已自动改写` };
1252
- auditConstraint(re.event);
1304
+ await auditConstraint(re.event);
1253
1305
  return { text: blockedOutputText(re), note: `⛔ 领域约束「${re.constraint?.id || cid}」改写后仍命中,已拦截` };
1254
1306
  }
1255
1307
  } catch {
@@ -1634,10 +1686,31 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1634
1686
  // (状态条/右栏已有首帧值)。长审计里本地模型会有十几次请求,重复打印只会刷屏。
1635
1687
  // 可选的"后续明显劣化警告"本轮未实现:现有 firstTokenAt 是回合级的,
1636
1688
  // 不能用来量后续请求的 TTFT(否则会得到负值),不为一条提示去改测量口径。
1637
- if (turnProgress.llmCalls === 1) {
1638
- try {
1639
- io.print(style(`⏱ 首帧 ${(ttft / 1000).toFixed(1)}s(第 ${turnProgress.llmCalls} 次模型请求;本地模型这一项直接反映 prefill 快慢)`, C.dim));
1640
- } catch {}
1689
+ // v0.6.31:会话级节流(见文件顶部 shouldPrintTtftNotice)——长审计里「继续」会产生
1690
+ // 很多新回合,每回合都打一次会把聊天区刷满;但首帧**明显变慢**时必须照常报(劣化信号)。
1691
+ {
1692
+ const nk = String(cfg?.sessionRef?.name || cfg?.sessionKey || workingDir || 'default');
1693
+ const st = ttftNoticeStateFor(nk);
1694
+ const verdict = shouldPrintTtftNotice({
1695
+ llmCalls: turnProgress.llmCalls,
1696
+ ttftMs: ttft,
1697
+ lastMs: st.ms,
1698
+ lastAt: st.at,
1699
+ now: Date.now(),
1700
+ quietMs: Number(cfg?.ttftNoticeQuietMs) > 0 ? Number(cfg.ttftNoticeQuietMs) : TTFT_NOTICE_DEFAULT_QUIET_MS,
1701
+ });
1702
+ if (verdict.print) {
1703
+ st.ms = ttft;
1704
+ st.at = Date.now();
1705
+ try {
1706
+ io.print(style(
1707
+ verdict.slowdown
1708
+ ? `⏱ 首帧 ${(ttft / 1000).toFixed(1)}s(比上次明显变慢,本地模型这一项直接反映 prefill 快慢)`
1709
+ : `⏱ 首帧 ${(ttft / 1000).toFixed(1)}s(第 ${turnProgress.llmCalls} 次模型请求;本地模型这一项直接反映 prefill 快慢)`,
1710
+ verdict.slowdown ? C.yellow : C.dim
1711
+ ));
1712
+ } catch {}
1713
+ }
1641
1714
  }
1642
1715
  markProgress('模型增量');
1643
1716
  if (d.text) io.writeText(d.text);
@@ -1937,8 +2010,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1937
2010
  // 审计(P3-5):参数与拒绝原因都记录(配置 audit:false 可关)
1938
2011
  const auditOn = cfg.audit !== false;
1939
2012
  const auditArgs = () => redactSecrets(JSON.stringify(args ?? {})).slice(0, 2000);
1940
- const auditEntry = (extra = {}) =>
1941
- writeAudit({
2013
+ // v0.7.0 §1.4:工具执行路径上的审计(每次工具调用一条)改异步写,锁等待让出事件循环。
2014
+ const auditEntry = async (extra = {}) =>
2015
+ writeAuditAsync({
1942
2016
  at: Date.now(),
1943
2017
  session: sessionRef?.name ?? null,
1944
2018
  model: modelName,
@@ -1953,7 +2027,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1953
2027
  if (!parsedArgs.ok) {
1954
2028
  // 参数 JSON 解析失败:回填错误给模型,不拿空参数去执行工具
1955
2029
  io.renderToolDenied(name, {}, parsedArgs.deniedReason);
1956
- if (auditOn) auditEntry({ denied: true, reason: parsedArgs.auditReason });
2030
+ if (auditOn) await auditEntry({ denied: true, reason: parsedArgs.auditReason });
1957
2031
  // 审计(长生成截断循环):给出明确分块指引,避免模型反复用单个超大 write 撞输出上限
1958
2032
  return { tc, name, denied: parsedArgs.message };
1959
2033
  }
@@ -1963,7 +2037,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1963
2037
  const hook = await hooks.pre(name, args);
1964
2038
  if (hook.decision === 'block') {
1965
2039
  io.renderToolDenied(name, args, '被钩子阻止');
1966
- if (auditOn) auditEntry({ denied: true, reason: `PreToolUse 钩子阻止:${String(hook.reason || '').slice(0, 200)}` });
2040
+ if (auditOn) await auditEntry({ denied: true, reason: `PreToolUse 钩子阻止:${String(hook.reason || '').slice(0, 200)}` });
1967
2041
  return { tc, name, denied: `工具被 PreToolUse 钩子阻止:${hook.reason}` };
1968
2042
  }
1969
2043
 
@@ -1971,7 +2045,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1971
2045
  // v0.4.0 Agent Preset:白名单强制(模型可能调用白名单外工具——schema 已不发,此处兜底硬拦)
1972
2046
  if (presetToolSet && !presetToolSet.has(isMcp ? name.slice(5) : name)) {
1973
2047
  io.renderToolDenied(name, args, `不在预设工具白名单内(${activePresetName || 'preset'})`);
1974
- if (auditOn) auditEntry({ denied: true, reason: '预设工具白名单拦截' });
2048
+ if (auditOn) await auditEntry({ denied: true, reason: '预设工具白名单拦截' });
1975
2049
  return { tc, name, denied: `工具 ${name} 不在当前预设的工具白名单内,已拒绝执行。` };
1976
2050
  }
1977
2051
  let allowed = false;
@@ -1989,7 +2063,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1989
2063
  }
1990
2064
  if (!allowed) {
1991
2065
  io.renderToolDenied(name, args, '未授权');
1992
- if (auditOn) auditEntry({ denied: true, reason: '未授权' });
2066
+ if (auditOn) await auditEntry({ denied: true, reason: '未授权' });
1993
2067
  // v0.6.0 C1:权限拒绝是「为什么没执行」的头号答案,必须入账
1994
2068
  turnLedger.permission({ name, mode: permission?.mode ?? cfg.permission ?? null, decision: 'deny', source: 'permission.check' });
1995
2069
  turnLedger.toolCall({ callId: tc.id, name, rawArgs: args, args, permission: { decision: 'deny' } });
@@ -2012,7 +2086,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2012
2086
  granted = false;
2013
2087
  }
2014
2088
  if (granted) {
2015
- if (auditOn) auditEntry({ confirmRequired: true, granted: true, reason: cv.reason });
2089
+ if (auditOn) await auditEntry({ confirmRequired: true, granted: true, reason: cv.reason });
2016
2090
  turnLedger.permission({ name, mode: permission?.mode ?? cfg.permission ?? null, decision: 'confirm-granted', source: 'constraint.confirm', rule: cv.event?.id ?? null });
2017
2091
  } else {
2018
2092
  confirmFailure = `领域约束「${cv.event?.id ?? ''}」要求人工确认,未获确认(拒绝或当前环境无法交互)——已按 fail-closed 阻止执行`;
@@ -2032,8 +2106,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2032
2106
  if (cv?.blocked || confirmFailure) {
2033
2107
  const reason = confirmFailure ?? cv?.reason ?? '领域约束拦截';
2034
2108
  io.renderToolDenied(name, args, reason);
2035
- if (auditOn) auditEntry({ denied: true, reason });
2036
- if (cv?.event) auditConstraint(cv.event);
2109
+ if (auditOn) await auditEntry({ denied: true, reason });
2110
+ if (cv?.event) await auditConstraint(cv.event);
2037
2111
  // 被约束拦下的调用不会有 tool.result,这里补一条终态,避免账本出现「只有调用没有结果」的悬空步
2038
2112
  turnLedger.toolResult({ callId: tc.id, name, ok: false, blocked: true, ms: 0, error: '领域约束拦截' });
2039
2113
  return { tc, name, denied: `【领域约束】${reason}` };
@@ -2087,7 +2161,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2087
2161
  }
2088
2162
 
2089
2163
  // 收尾:渲染结果 → todo 更新 → PostToolUse 钩子 → 回填消息(顺序与串行一致)
2090
- function finishTool(/** @type {any} */ prep, /** @type {any} */ result, /** @type {any} */ t0) {
2164
+ // v0.7.0 §1.4:审计写改为异步(工具执行路径),收尾顺序用 await 保持原序。
2165
+ async function finishTool(/** @type {any} */ prep, /** @type {any} */ result, /** @type {any} */ t0) {
2091
2166
  const ms = Date.now() - t0;
2092
2167
  toolMsTotal += ms;
2093
2168
  // v0.6.13(A):**只有"成功且不重复"的工具结果才算进展**。
@@ -2132,7 +2207,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2132
2207
  rObj = null;
2133
2208
  }
2134
2209
  }
2135
- writeAudit({
2210
+ await writeAuditAsync({
2136
2211
  at: Date.now(),
2137
2212
  session: sessionRef?.name ?? null,
2138
2213
  model: modelName,
@@ -2168,7 +2243,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2168
2243
  // v0.5.0 A3 ②:领域约束(PostToolUse)——completeness 缺项时**拒绝该工具结果**,
2169
2244
  // 让模型必须继续采集而不是把「未提及」当作已完成(「缺项绝不编造」从提示词升级为内核强制)。
2170
2245
  const pv = checkPostTool(constraints, prep.name, typeof result === 'string' ? safeParse(result) : result);
2171
- if (pv?.rejected) auditConstraint(pv.event);
2246
+ if (pv?.rejected) await auditConstraint(pv.event);
2172
2247
  // 回填正文(复用前缀 / 约束拒绝文案 / 按窗口截断)单源在 tools-flow.js 的 toolResultMessage()
2173
2248
  // v0.6.29(H-02):不在这里直接 push——先按 `tool_calls` 原序入队,整批结束后统一回填。
2174
2249
  pendingToolMessages.push({
@@ -2229,7 +2304,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2229
2304
  return runTool(b.prep).then((r) => ({ r, t0 }));
2230
2305
  })
2231
2306
  );
2232
- batch.forEach((b, idx) => finishTool(b.prep, results[idx].r, results[idx].t0));
2307
+ for (let idx = 0; idx < batch.length; idx += 1) await finishTool(batch[idx].prep, results[idx].r, results[idx].t0);
2233
2308
  } else if (allBatchable && prefix.length > 1) {
2234
2309
  // 前导只读子批并行 + 其余串行
2235
2310
  const results = await Promise.all(
@@ -2238,14 +2313,14 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2238
2313
  return runTool(b.prep).then((r) => ({ r, t0 }));
2239
2314
  })
2240
2315
  );
2241
- prefix.forEach((b, idx) => finishTool(b.prep, results[idx].r, results[idx].t0));
2316
+ for (let idx = 0; idx < prefix.length; idx += 1) await finishTool(prefix[idx].prep, results[idx].r, results[idx].t0);
2242
2317
  for (const b of batch.slice(prefix.length)) {
2243
2318
  if (b.prep.denied != null) {
2244
2319
  finishDenied(b.prep);
2245
2320
  continue;
2246
2321
  }
2247
2322
  const t0 = Date.now();
2248
- finishTool(b.prep, await runTool(b.prep), t0);
2323
+ await finishTool(b.prep, await runTool(b.prep), t0);
2249
2324
  }
2250
2325
  } else {
2251
2326
  for (const b of batch) {
@@ -2254,7 +2329,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2254
2329
  continue;
2255
2330
  }
2256
2331
  const t0 = Date.now();
2257
- finishTool(b.prep, await runTool(b.prep), t0);
2332
+ await finishTool(b.prep, await runTool(b.prep), t0);
2258
2333
  }
2259
2334
  }
2260
2335
  }
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, withFileLockSync } from './atomic-write.js';
11
+ import { atomicWriteFileSync, withFileLockSync, withFileLock } from './atomic-write.js';
12
12
 
13
13
  // v0.6.2(第三方代码审计 P2-2):截断触发改为看**文件大小**。
14
14
  // 原判据 `auditCount > 20000` 是**进程内**计数:CLI 每次会话只 append 几次、进程结束即归零,
@@ -36,47 +36,70 @@ export function auditWriteError() {
36
36
  return auditError;
37
37
  }
38
38
 
39
+ /** 锁内临界区:追加 + 低频轮转(**追加与轮转必须同一把锁**,v0.6.3 M-20)。
40
+ * 原实现锁外追加 + 锁外读改写轮转:A 追加 → B 追加 → A 读到含 A 的快照 → A 原子写回 → B 那一行消失。
41
+ * 审计是合规证据,"少一行"等于证据链有洞,而且不报错。锁的范围含 append:代价是每次
42
+ * 工具调用多一次毫秒级文件锁(审计本就每工具调用一条)。
43
+ * @param {any} entry */
44
+ function appendAuditUnderLock(/** @type {any} */ entry) {
45
+ const file = auditFile();
46
+ fs.appendFileSync(file, JSON.stringify(entry) + '\n');
47
+ try {
48
+ fs.chmodSync(file, 0o600);
49
+ } catch {}
50
+ // 低频截断:statSync 廉价,只有真的超过阈值才整文件读一次并重写
51
+ if (fs.statSync(file).size > MAX_BYTES) {
52
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
53
+ if (lines.length > KEEP_LINES) {
54
+ // v0.6.2(P2-3):**原子写**——原先直接 writeFileSync,截断过程中崩溃会留下半截
55
+ // audit.jsonl(审计证据丢事件)。atomicWriteFileSync 的 tmp 名含 pid+随机后缀,
56
+ // 写完 rename 原子替换,读者永远看到完整文件;mode 保持 0600。
57
+ atomicWriteFileSync(file, lines.slice(-KEEP_LINES).join('\n') + '\n', { mode: 0o600 });
58
+ }
59
+ }
60
+ }
61
+
62
+ /** 写失败的统一可见化:审计不能反过来打断用户干活,但**不再无声**。
63
+ * @param {any} err */
64
+ function auditFailure(/** @type {any} */ err) {
65
+ auditFailures += 1;
66
+ auditError = String(/** @type {any} */ (err)?.message ?? err);
67
+ if (!auditWarned) {
68
+ auditWarned = true;
69
+ console.warn(
70
+ `[MingDao] ⚠ 审计记录写入失败:${auditError}\n` +
71
+ ` 该次运行的 audit.jsonl 会缺事件(执行账本 ledger/ 不受影响);请检查 ${auditFile()} 的磁盘空间与权限。`
72
+ );
73
+ }
74
+ }
75
+
76
+ /**
77
+ * 写一条工具调用审计(**同步版**:CLI/REPL 与任何纯同步调用链;语义不变、失败不阻断主流程)。
78
+ * @param {any} entry
79
+ */
39
80
  export function writeAudit(/** @type {any} */ entry) {
40
81
  try {
41
82
  ensureHome();
42
- const file = auditFile();
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
- });
83
+ withFileLockSync(auditFile() + '.lock', () => appendAuditUnderLock(entry));
84
+ } catch (err) {
85
+ auditFailure(err);
86
+ }
87
+ }
65
88
 
89
+ /**
90
+ * 写一条工具调用审计 —— **WebUI 工具执行路径用它(v0.7.0 §1.4)**。
91
+ * 每次工具调用都会走到这里;同步版在锁争用时用 `Atomics.wait` 睡眠,会把整个 WebUI
92
+ * (所有会话 / 权限确认 / SSE 流)冻住。`await withFileLock` 等待时让出事件循环。
93
+ * 失败语义与同步版一致:只记计数/告警,绝不抛给主流程。
94
+ * @param {any} entry
95
+ * @returns {Promise<void>}
96
+ */
97
+ export async function writeAuditAsync(/** @type {any} */ entry) {
98
+ try {
99
+ ensureHome();
100
+ await withFileLock(auditFile() + '.lock', () => appendAuditUnderLock(entry));
66
101
  } catch (err) {
67
- // v0.6.2(B-WS-1/2 第八处):仍然「不影响会话」(审计不能反过来打断用户干活),
68
- // 但**不再无声**——此前连"这次运行的审计记录是空的"都无从得知。
69
- // 保留计数与原因供诊断/测试;一次性 console.warn 避免每步刷屏。
70
- auditFailures += 1;
71
- auditError = String(/** @type {any} */ (err)?.message ?? err);
72
- if (!auditWarned) {
73
- auditWarned = true;
74
- console.warn(
75
- `[MingDao] ⚠ 审计记录写入失败:${auditError}\n` +
76
- ` 该次运行的 audit.jsonl 会缺事件(执行账本 ledger/ 不受影响);请检查 ${auditFile()} 的磁盘空间与权限。`
77
- );
78
- }
79
- return;
102
+ auditFailure(err);
80
103
  }
81
104
  }
82
105