mingdao-harness 0.6.31 → 0.6.33

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.
@@ -1,6 +1,6 @@
1
1
  # MingDao-Harness 架构设计
2
2
 
3
- > 版本口径:v0.6.30。本文档覆盖 `src/` 全部 **90** 个模块(`find src -name '*.js' | wc -l` = 90),
3
+ > 版本口径:v0.6.30。本文档覆盖 `src/` 全部 **91** 个模块(`find src -name '*.js' | wc -l` = 91),
4
4
  > 按层给出每个模块的职责与它守住的那条不变量;判定以文件头注释与实际实现为准,不做推断性描述。
5
5
  >
6
6
  > 阅读顺序建议:§2 模块索引(查「某件事归谁管」)→ §3 数据流(看一次回合怎么走)→ §4 已知边界(看它**保证不了**什么)。
@@ -54,7 +54,7 @@ MingDao 是一个「模型循环 + 工具 + 权限」内核,纯 Node.js ≥ 18
54
54
 
55
55
  ## 2. 模块索引
56
56
 
57
- 以下逐文件列出 `src/` 全部 90 个模块。每条格式为「职责 —— 关键不变量/边界」。
57
+ 以下逐文件列出 `src/` 全部 91 个模块。每条格式为「职责 —— 关键不变量/边界」。
58
58
 
59
59
  ### 2.1 内核循环与上下文(6)
60
60
 
@@ -131,6 +131,8 @@ MingDao 是一个「模型循环 + 工具 + 权限」内核,纯 Node.js ≥ 18
131
131
  - `web/constants.js` —— 前端与服务端共享常量:并发上限 8(`MAX_CONCURRENT`)、附件数与体积上限、单文件读取上限 —— 数值只在此定义,防客户端预检/服务端校验/工具读取三处漂移。
132
132
  - `web/attachments.js` —— 聊天附件构造(纯函数):图片转 `data:` URL(仅视觉模型,单张 ≤5MB、最多 4 个),文本文件拼接进消息(≤200KB)—— 服务端按同一组常量校验。
133
133
  - `web/web-io.js` —— WebUI 的 io 适配器:把 Agent 的 io 事件翻译为 SSE 事件,权限确认/选择类交互经 `askHandler` 转发到浏览器模态框并等待 `POST /api/permission` 应答。
134
+ - `web/viewmodel.js`(v0.7.0 §1.2)—— 前端的**纯计算层**:`sideNavLayout`(三档断点下的左栏形态)、`errCardParts`(错误卡文案分解)、`runStripView`(状态条六字段)、`traceRoundView`(轨迹一轮的视图模型)以及它们依赖的格式化助手。**不读 DOM、不读全局**,因此可以被 node 直测;`app.js` 只负责把结果渲染到 DOM。
135
+ 为什么抽出来:此前 `app.js` 3252 行、可执行覆盖 0%,只靠源码正则守卫——UI 改动的"绿"等于"文本没变"。
134
136
  - `web/util.js` / `web/app.js` / `web/i18n.js` —— 前端:`util.js` 是零状态工具(选择器/转义/Markdown/格式化),`app.js` 是页面主脚本(消息渲染与轨迹、设置面板、自绘气泡与模态框),`i18n.js` 是零依赖 UI 文案表(zh/en 同键、默认跟随 `navigator.language`、`localStorage` 覆盖)—— 地址带 `?token=` 时记入 `sessionStorage` 并从地址栏移除,此后请求统一附加 `X-MingDao-Token`。
135
137
 
136
138
  ### 2.8 CLI 层(13)
@@ -172,7 +174,7 @@ MingDao 是一个「模型循环 + 工具 + 权限」内核,纯 Node.js ≥ 18
172
174
  - `index.js` —— 库公共 API:导出 `@stable`(minor 内向后兼容)与 `@experimental` 两组能力,契约与示例见 DEVELOPER.md。
173
175
 
174
176
  模块计数核对:6 + 8 + 12 + 10 + 3 + 2 + 13 + 13 + 19 = **86** 个条目(含合并条目;`find src -name '*.js' | wc -l` = **90**),其中 §2.7 的
175
- `web/util.js` / `web/app.js` 一条覆盖 2 个文件,故条目覆盖 **90** 个 `src/**/*.js`(每版本以 `find` 实测为准),
177
+ `web/util.js` / `web/app.js` 一条覆盖 2 个文件、`web/viewmodel.js` 单列,故条目覆盖 **91** 个 `src/**/*.js`(每版本以 `find` 实测为准),
176
178
  与 `find src -name '*.js' | wc -l` 的结果一致。
177
179
 
178
180
  ## 3. 数据流:一次 `runTurn` 的关键路径
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,187 @@ 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 的实测值重建基线并在提交说明里写明原因。
321
+
322
+ ## 八、变异覆盖盲区检查(v0.7.0 §1.2)
323
+
324
+ > 变异验证回答的是「**改坏了的代码会不会被抓到**」;它不回答「从没被改过的模块安不安全」。
325
+ > 2026-11 的自审实测:633→669 条变异里 4 个文件占了 285 条,而 90 个模块里 43 个**一条变异都没有**。
326
+ > 分数高只说明「改过的都测了」,盲区模块在报告里是**沉默**的——这一节把沉默变成可复跑的清单。
327
+
328
+ ### 8.1 三件套:行为套件 → 变异批次 → 盲区检查
329
+
330
+ 给一个「有真实逻辑但 0 条变异」的模块补覆盖,顺序不能颠倒:
331
+
332
+ 1. **先建行为套件**(`test/<模块>-<主题>.js`)。没有套件就没有可抓的变异。
333
+ - 调**真实函数**看结果,不做源码文本匹配;
334
+ - 用本机临时目录(`fs.mkdtempSync(path.join(os.tmpdir(), ...))`)+ 桩服务端,不连网;
335
+ - 每条断言带稳定前缀(如 `E1:`、`P7:`、`W13:`),后面写变异批次要用它当关键词。
336
+ 2. **再写变异批次**(`test/mutate/batchNN-<主题>.mjs`,参照 `batch72`/`73`/`74`)。
337
+ - 只改 `src/**` 里**最容易写错**的那一行:整键替换 vs 合并、mtime 丢失、围栏少一层 realpath、
338
+ 软链不判、能力声明不校验、错误被吞……;
339
+ - 每条都在套件上**真跑一遍**,必须红;
340
+ - `expect` 填**失败输出里真实出现的关键词**(断言原文的一部分)。
341
+ 本仓在这上面踩过两次:一次是 `expect` 写了同一断言组里另一条分支的文案;一次是
342
+ `'工具名非法'` 同时出现在 `registerTool` 的兜底报错里,变异把 Pack 层主动告警删掉后
343
+ **仍然命中**、看起来"抓到了"。所以 `expect` 要尽量长、尽量是**你删掉的那条路径**独有的话。
344
+ 3. **跑一遍盲区检查**(下节),确认这个模块从清单里消失。
345
+
346
+ ### 8.2 盲区检查:`scripts/mutation-coverage.mjs`
347
+
348
+ ```bash
349
+ node scripts/mutation-coverage.mjs # 只报告(退出 0)
350
+ node scripts/mutation-coverage.mjs --fail-over 0 # 盲区数 > 0 即失败(CI 卡新增盲区)
351
+ node scripts/mutation-coverage.mjs --fail-over 13 # 只允许存量 13 个(存量基线,逐步下调)
352
+ node scripts/mutation-coverage.mjs --min-lines 150 # 调整"有真实逻辑"的代码行门槛
353
+ node scripts/mutation-coverage.mjs --json # 机器可读
354
+ ```
355
+
356
+ 口径(简单、可解释):
357
+
358
+ - **代码行** = 去掉空行与纯注释行后的行数(逐字符识别 `//`、`/* */`;字符串里的 `//` 不算注释)。
359
+ - **变异条数** = 该文件在全部 `test/mutate/batch*.mjs` 的 `file:` 字段里出现的次数(只统计 `src/**`)。
360
+ - **盲区** = 代码行 > `--min-lines`(默认 150)且变异条数 = 0。
361
+
362
+ `--fail-over N` 的语义:**盲区模块数 > N 时非零退出**;不带该参数绝不失败(只报告)。
363
+ CI 想"只卡新增盲区"就用当前存量数当基线(例如 `--fail-over 13`),想"一个都不能有"就用 0。
364
+ 本任务**未**把它接进 `.github/workflows/ci.yml`(不在允许改动范围)——接线时注意:直接上
365
+ `--fail-over 0` 会让今天的存量盲区全部变红,应先按存量收敛。
366
+
367
+ ### 8.3 当前盲区清单(2026-11-04,v0.6.32 / HEAD b5cd199 + 本任务三批变异)
368
+
369
+ `node scripts/mutation-coverage.mjs` 的实跑输出(如实抄录;数字随后续批次变化,**以脚本输出为准**):
370
+
371
+ ```text
372
+ 变异覆盖盲区检查(阈值:代码行 > 150 且 0 条变异)
373
+ src 模块:91 个;src/** 变异条目:630 条;盲区模块:13 个
374
+
375
+ 模块(按代码行降序):
376
+ 模块 代码行 变异
377
+ src/ui.js 600 0
378
+ src/mcp.js 303 0
379
+ src/tokenizer.js 302 0
380
+ src/commands/sync.js 288 0
381
+ src/update.js 244 0
382
+ src/web/viewmodel.js 243 0
383
+ src/web/i18n.js 225 0
384
+ src/commands/skill.js 218 0
385
+ src/web/util.js 198 0
386
+ src/commands/update.js 187 0
387
+ src/commands/schedule.js 181 0
388
+ src/skill-registry.js 171 0
389
+ src/compact.js 154 0
390
+
391
+ 合计:13 个模块、3314 行代码没有可抓的变异。
392
+ ```
393
+
394
+ 本任务补掉的三处(此前均 0 条):
395
+
396
+ | 模块 | 行为套件 | 变异批次 | 条数 |
397
+ | --- | --- | --- | --- |
398
+ | `src/sync.js` 状态增量 / 冲突 / 边界 | `test/sync-state.js`(16 项) | `test/mutate/batch72-sync-state.mjs` | 10 |
399
+ | `src/packs.js` manifest / 版本 / 能力声明 / 信任门 | `test/packs-capabilities.js`(16 项) | `test/mutate/batch73-packs.mjs` | 10 |
400
+ | `src/workspace.js` 注册表 / 归一 / 跨进程锁 | `test/workspace-fence.js`(15 项) | `test/mutate/batch74-workspace.mjs` | 11 |
401
+
402
+ > 模块边界如实说明:`src/workspace.js` 本身**不含**路径围栏(`withinRoot`),围栏真实实现于
403
+ > `src/tools/fs-tools.js` 与 `src/web/routes/domains/workspace.js`。`batch74` 的 11 条里有 8 条打在
404
+ > `src/workspace.js`,2 条打在前者的 `withinRoot`,2 条打在后者(允许根 / realpath 两层围栏)。
405
+ > 另外 `src/sync.js` 在本任务前已有 1 条(`batch70` ⑦),并非严格 0 条。
406
+
407
+ ### 8.4 边界
408
+
409
+ - 本任务的三个新套件**没有**接进 `test/run-all.mjs` 与 CI(不在允许改动范围)。要接的话,
410
+ 在 `SUITES` 里加 `['sync-state', 'node', ['test/sync-state.js']]` 等三行即可;
411
+ 三个套件都是零外部依赖、只用本机临时目录。
412
+ - 盲区检查只回答"有没有变异",不回答"变异质量"——`--fail-over` 只能挡住新增盲区,
413
+ 挡不住"给一个 600 行模块写 1 条无关变异"这种凑数。质量仍靠 review 与 `expect` 关键词约束。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.31",
3
+ "version": "0.6.33",
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';
@@ -642,7 +642,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
642
642
  Array.isArray(rawConstraints) ? rawConstraints : cfg?.constraints ?? getActivePackContext()?.constraints
643
643
  );
644
644
  /** 约束事件写审计(pack/constraint/kind/stage/action)——受监管场景要能回答「红线何时被触发」 */
645
- 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) => {
646
648
  // v0.6.0 C1:账本与 audit 在此**同一处**记录——三个时机(pre/post/output)都汇聚到这里,
647
649
  // 新增时机时不需要记得再补一处账本埋点。
648
650
  // 只记「哪条约束、什么时机、如何处理」,**不记命中的原文**(否则账本自身成为泄露渠道)。
@@ -651,7 +653,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
651
653
  }
652
654
  if (cfg.audit === false || !ev) return;
653
655
  try {
654
- 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 });
655
657
  } catch {}
656
658
  };
657
659
  const todos = /** @type {any[]} */ ([]);
@@ -1267,7 +1269,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1267
1269
  if (!constraints.active || !text) return { text, note: null };
1268
1270
  const hit = checkOutput(constraints, text);
1269
1271
  if (!hit) return { text, note: null };
1270
- auditConstraint(hit.event);
1272
+ await auditConstraint(hit.event);
1271
1273
  const cid = hit.constraint?.id || '领域红线';
1272
1274
  if (hit.action === 'warn') {
1273
1275
  try { io.print(style(`⚠ 领域约束「${cid}」提示:${hit.reason}`, C.yellow)); } catch {}
@@ -1299,7 +1301,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1299
1301
  if (fixed) {
1300
1302
  const re = checkOutput(constraints, fixed);
1301
1303
  if (!re) return { text: fixed, note: `⛔ 领域约束「${cid}」命中,已自动改写` };
1302
- auditConstraint(re.event);
1304
+ await auditConstraint(re.event);
1303
1305
  return { text: blockedOutputText(re), note: `⛔ 领域约束「${re.constraint?.id || cid}」改写后仍命中,已拦截` };
1304
1306
  }
1305
1307
  } catch {
@@ -2008,8 +2010,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2008
2010
  // 审计(P3-5):参数与拒绝原因都记录(配置 audit:false 可关)
2009
2011
  const auditOn = cfg.audit !== false;
2010
2012
  const auditArgs = () => redactSecrets(JSON.stringify(args ?? {})).slice(0, 2000);
2011
- const auditEntry = (extra = {}) =>
2012
- writeAudit({
2013
+ // v0.7.0 §1.4:工具执行路径上的审计(每次工具调用一条)改异步写,锁等待让出事件循环。
2014
+ const auditEntry = async (extra = {}) =>
2015
+ writeAuditAsync({
2013
2016
  at: Date.now(),
2014
2017
  session: sessionRef?.name ?? null,
2015
2018
  model: modelName,
@@ -2024,7 +2027,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2024
2027
  if (!parsedArgs.ok) {
2025
2028
  // 参数 JSON 解析失败:回填错误给模型,不拿空参数去执行工具
2026
2029
  io.renderToolDenied(name, {}, parsedArgs.deniedReason);
2027
- if (auditOn) auditEntry({ denied: true, reason: parsedArgs.auditReason });
2030
+ if (auditOn) await auditEntry({ denied: true, reason: parsedArgs.auditReason });
2028
2031
  // 审计(长生成截断循环):给出明确分块指引,避免模型反复用单个超大 write 撞输出上限
2029
2032
  return { tc, name, denied: parsedArgs.message };
2030
2033
  }
@@ -2034,7 +2037,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2034
2037
  const hook = await hooks.pre(name, args);
2035
2038
  if (hook.decision === 'block') {
2036
2039
  io.renderToolDenied(name, args, '被钩子阻止');
2037
- 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)}` });
2038
2041
  return { tc, name, denied: `工具被 PreToolUse 钩子阻止:${hook.reason}` };
2039
2042
  }
2040
2043
 
@@ -2042,7 +2045,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2042
2045
  // v0.4.0 Agent Preset:白名单强制(模型可能调用白名单外工具——schema 已不发,此处兜底硬拦)
2043
2046
  if (presetToolSet && !presetToolSet.has(isMcp ? name.slice(5) : name)) {
2044
2047
  io.renderToolDenied(name, args, `不在预设工具白名单内(${activePresetName || 'preset'})`);
2045
- if (auditOn) auditEntry({ denied: true, reason: '预设工具白名单拦截' });
2048
+ if (auditOn) await auditEntry({ denied: true, reason: '预设工具白名单拦截' });
2046
2049
  return { tc, name, denied: `工具 ${name} 不在当前预设的工具白名单内,已拒绝执行。` };
2047
2050
  }
2048
2051
  let allowed = false;
@@ -2060,7 +2063,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2060
2063
  }
2061
2064
  if (!allowed) {
2062
2065
  io.renderToolDenied(name, args, '未授权');
2063
- if (auditOn) auditEntry({ denied: true, reason: '未授权' });
2066
+ if (auditOn) await auditEntry({ denied: true, reason: '未授权' });
2064
2067
  // v0.6.0 C1:权限拒绝是「为什么没执行」的头号答案,必须入账
2065
2068
  turnLedger.permission({ name, mode: permission?.mode ?? cfg.permission ?? null, decision: 'deny', source: 'permission.check' });
2066
2069
  turnLedger.toolCall({ callId: tc.id, name, rawArgs: args, args, permission: { decision: 'deny' } });
@@ -2083,7 +2086,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2083
2086
  granted = false;
2084
2087
  }
2085
2088
  if (granted) {
2086
- if (auditOn) auditEntry({ confirmRequired: true, granted: true, reason: cv.reason });
2089
+ if (auditOn) await auditEntry({ confirmRequired: true, granted: true, reason: cv.reason });
2087
2090
  turnLedger.permission({ name, mode: permission?.mode ?? cfg.permission ?? null, decision: 'confirm-granted', source: 'constraint.confirm', rule: cv.event?.id ?? null });
2088
2091
  } else {
2089
2092
  confirmFailure = `领域约束「${cv.event?.id ?? ''}」要求人工确认,未获确认(拒绝或当前环境无法交互)——已按 fail-closed 阻止执行`;
@@ -2103,8 +2106,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2103
2106
  if (cv?.blocked || confirmFailure) {
2104
2107
  const reason = confirmFailure ?? cv?.reason ?? '领域约束拦截';
2105
2108
  io.renderToolDenied(name, args, reason);
2106
- if (auditOn) auditEntry({ denied: true, reason });
2107
- if (cv?.event) auditConstraint(cv.event);
2109
+ if (auditOn) await auditEntry({ denied: true, reason });
2110
+ if (cv?.event) await auditConstraint(cv.event);
2108
2111
  // 被约束拦下的调用不会有 tool.result,这里补一条终态,避免账本出现「只有调用没有结果」的悬空步
2109
2112
  turnLedger.toolResult({ callId: tc.id, name, ok: false, blocked: true, ms: 0, error: '领域约束拦截' });
2110
2113
  return { tc, name, denied: `【领域约束】${reason}` };
@@ -2158,7 +2161,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2158
2161
  }
2159
2162
 
2160
2163
  // 收尾:渲染结果 → todo 更新 → PostToolUse 钩子 → 回填消息(顺序与串行一致)
2161
- 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) {
2162
2166
  const ms = Date.now() - t0;
2163
2167
  toolMsTotal += ms;
2164
2168
  // v0.6.13(A):**只有"成功且不重复"的工具结果才算进展**。
@@ -2203,7 +2207,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2203
2207
  rObj = null;
2204
2208
  }
2205
2209
  }
2206
- writeAudit({
2210
+ await writeAuditAsync({
2207
2211
  at: Date.now(),
2208
2212
  session: sessionRef?.name ?? null,
2209
2213
  model: modelName,
@@ -2239,7 +2243,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2239
2243
  // v0.5.0 A3 ②:领域约束(PostToolUse)——completeness 缺项时**拒绝该工具结果**,
2240
2244
  // 让模型必须继续采集而不是把「未提及」当作已完成(「缺项绝不编造」从提示词升级为内核强制)。
2241
2245
  const pv = checkPostTool(constraints, prep.name, typeof result === 'string' ? safeParse(result) : result);
2242
- if (pv?.rejected) auditConstraint(pv.event);
2246
+ if (pv?.rejected) await auditConstraint(pv.event);
2243
2247
  // 回填正文(复用前缀 / 约束拒绝文案 / 按窗口截断)单源在 tools-flow.js 的 toolResultMessage()
2244
2248
  // v0.6.29(H-02):不在这里直接 push——先按 `tool_calls` 原序入队,整批结束后统一回填。
2245
2249
  pendingToolMessages.push({
@@ -2300,7 +2304,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2300
2304
  return runTool(b.prep).then((r) => ({ r, t0 }));
2301
2305
  })
2302
2306
  );
2303
- 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);
2304
2308
  } else if (allBatchable && prefix.length > 1) {
2305
2309
  // 前导只读子批并行 + 其余串行
2306
2310
  const results = await Promise.all(
@@ -2309,14 +2313,14 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2309
2313
  return runTool(b.prep).then((r) => ({ r, t0 }));
2310
2314
  })
2311
2315
  );
2312
- 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);
2313
2317
  for (const b of batch.slice(prefix.length)) {
2314
2318
  if (b.prep.denied != null) {
2315
2319
  finishDenied(b.prep);
2316
2320
  continue;
2317
2321
  }
2318
2322
  const t0 = Date.now();
2319
- finishTool(b.prep, await runTool(b.prep), t0);
2323
+ await finishTool(b.prep, await runTool(b.prep), t0);
2320
2324
  }
2321
2325
  } else {
2322
2326
  for (const b of batch) {
@@ -2325,7 +2329,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2325
2329
  continue;
2326
2330
  }
2327
2331
  const t0 = Date.now();
2328
- finishTool(b.prep, await runTool(b.prep), t0);
2332
+ await finishTool(b.prep, await runTool(b.prep), t0);
2329
2333
  }
2330
2334
  }
2331
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