mingdao-harness 0.6.5 → 0.6.7
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 +92 -0
- package/docs/RELEASE-CHECKLIST.md +29 -1
- package/package.json +1 -1
- package/src/agent.js +90 -15
- package/src/batch.js +57 -4
- package/src/cachestats.js +36 -4
- package/src/commands/pack.js +6 -1
- package/src/commands/repl.js +4 -1
- package/src/config.js +9 -1
- package/src/constraints.js +10 -6
- package/src/context.js +8 -1
- package/src/cost-guard.js +63 -22
- package/src/credentials.js +11 -2
- package/src/ledger.js +11 -1
- package/src/model-caps.js +1 -1
- package/src/net-guard.js +108 -26
- package/src/permissions.js +29 -2
- package/src/providers/index.js +9 -1
- package/src/redact.js +15 -4
- package/src/regex-safety.js +137 -0
- package/src/safe-fetch.js +22 -32
- package/src/schedule.js +47 -33
- package/src/session.js +13 -2
- package/src/skill-lib.js +1 -2
- package/src/ssrf-guard.js +156 -0
- package/src/sync-server.js +40 -32
- package/src/tasks/worker.js +8 -1
- package/src/tools/bash.js +5 -1
- package/src/tools/fetch.js +33 -186
- package/src/tools/fs-tools.js +23 -19
- package/src/web/app.js +1 -0
- package/src/web/routes/domains/config.js +8 -3
- package/src/web/routes/domains/workspace.js +6 -1
- package/src/web/server.js +16 -18
|
@@ -1158,3 +1158,95 @@ P1-20b 就是这么找出来的。同理,P0-1 的断言刻意包含**反向用
|
|
|
1158
1158
|
(与 v0.6.4 批一同一处的漏网)③ 并发读-改-写丢数据四条(`memory` / `tasks` / `tasks.worker` / `log-writer`)
|
|
1159
1159
|
④ 截断与轮转两条(触发看字节、守卫看行数)⑤ 结构类"单一来源"整改 ⑥ 测试可信度(约 53 处断言作用于
|
|
1160
1160
|
**未剥注释**的源码文本)。另:P1-21(`workspace.js` 符号链接"注册后改指向"击穿围栏)已列为本批之后第一项。
|
|
1161
|
+
|
|
1162
|
+
## 3.40 第三方技术评估(v0.6.5,2026-09-23):Windows 测试门禁三处
|
|
1163
|
+
|
|
1164
|
+
**来源**:第三方对 `3bbc7f7`(v0.6.5)的评估报告 + 随附 PR/补丁(`MingDao-harness技术评估报告_v0.6.5.md`、
|
|
1165
|
+
`mingdao-0.6.5-PR-Windows测试门禁修复.md`、`mingdao-0.6.5-fix-windows-test-gates.patch`)。
|
|
1166
|
+
总评 8.9/10,扣分项集中在**「三平台全绿」在真实 Windows 开发机上不成立**(本登记簿按本仓纪律逐条复现后处理)。
|
|
1167
|
+
|
|
1168
|
+
| # | 报告结论 | 我的复现/判定 | 处置 |
|
|
1169
|
+
| --- | --- | --- | --- |
|
|
1170
|
+
| 4.3 | `test/run-all.mjs` 在 Windows `spawn EINVAL` 崩溃 → **bench 的 214 条断言从不执行** | ✅ **成立(代码级确认 + 文档依据)**:Node ≥18.20.2/20.12.2(CVE-2024-27980 那批)起 spawn `.cmd`/`.bat` 无 shell **同步**抛 EINVAL;`on('error')` 收不到(ChildProcess 未被创建),Promise 直接 reject → 汇总器崩溃。**CI 从未暴露**:`npm run coverage`(run-all 的唯一入口)只在 `Linux + Node 20` 那条腿上跑 | ✅ 已修:win32 加 `shell: true` + 同步 `try/catch`(记为套件失败,不崩汇总器);`test/smoke.js` §125 加**源码级**守卫(本机无法行为级复现,如实标注层级) |
|
|
1171
|
+
| 4.2 | `e2e-schedule` 租约自退用例确定性失败;根因是 `every` 的等待是整段 ≤60s 且**期间不查租约** | ✅ **成立(主审复现)**:写探针——`--every 60s` 任务 + 预热 5s 让 sleeper 进入等待片,再篡改 pidfile 租约 → **>40s 旧 daemon 仍存活**(上限 60s)。修复后同一探针 **4.0s 退出**。**顺带查出 `once` 分支有同一处**(报告只点了 `every`) | ✅ 已修:`every`/`once` 的等待都改走 `waitGuarded(…, 3000)`(≤3s 切片逐片查租约),最坏接管延迟 60s → ≈5s(监督轮询 2s + 一片 3s)。**行为级断言**:接管延迟 ≤12s |
|
|
1172
|
+
| 4.1 | 标准 Windows 账户下 `fs.symlinkSync` **不抛错也不建链接**(生成普通空文件)→ smoke 崩在软链用例、后续约 140 组断言不执行 | 🟡 **结论成立、机制按我的核实改写**:Node 在无权限时通常是**抛 EPERM**(多家项目因此改用 junction,见下);"静默生成普通文件"更像**该审计沙箱的文件系统降级**行为,不是标准 Windows 的默认行为——但**两种环境都必须被正确处理**,而且报告的第二个洞是硬的:`try { symlinkSync } catch {}` 这种写法**只判"有没有抛错",不判"有没有建出链接"** | ✅ 已修(比原补丁更彻底):新增 `makeRealSymlink()` 能力探测(`lstat().isSymbolicLink()` 复核)并替换**全部 6 处**软链用例(报告只点了 2 处);建不成显式打印「⚠ 跳过」;新增 §125 反向自检(smoke.js 里再出现裸 `fs.symlinkSync` 即测试失败) |
|
|
1173
|
+
| 4.1(后半) | 建议把 `withinRoot()` 导出为纯函数,用桩 fs 做不依赖平台权限的单测 | ✅ 采纳并落地 | ✅ `withinRoot(root, target, depth, io = fs)` 支持注入 fs 并导出;新增 `test/smoke.js` §124:悬空指向外(拒)/悬空指向内(放行)/成环(有界拒,断言 `readlink` 调用 ≤20)/新建文件(放行)/根与子路径(放行),**任何平台都跑**;另加探测器自身的桩测试(降级环境 / 真建成 / EPERM 三种) |
|
|
1174
|
+
|
|
1175
|
+
**变异验证 7/7**(把每处修复逐个改回缺陷,断言当场失败):围栏递归解析、能力探测判据、裸 symlinkSync 回归、
|
|
1176
|
+
run-all 的 shell、run-all 的 try/catch、调度切片(源码级)、调度切片(行为级)。
|
|
1177
|
+
|
|
1178
|
+
**方法论收获(与前几批同源)**:这次的四处问题里**三处**都不是"代码写错了",而是
|
|
1179
|
+
**判据只落在能验证的那一侧**:CI 只在 Linux 上跑 run-all;软链用例只判"抛不抛错";
|
|
1180
|
+
测试计时依赖"daemon 恰好已经进入等待片"(我在补丁里加了预热,让它变成确定性用例——
|
|
1181
|
+
否则"修复前后都通过"的假绿会一直在)。**"没报错"和"验证过了"是两件事。**
|
|
1182
|
+
|
|
1183
|
+
**未纳入本批(如实登记,按报告 §7 排期)**:
|
|
1184
|
+
- **P0-2 在线缓存命中基准**(`bench-cache-online.mjs`,需真实 Key、CI 可选)——这是"删 token 是否反而
|
|
1185
|
+
破坏缓存"的唯一裁决方式,报告指出 bench 里"回收不额外劣化"目前是**推理而非实测**。需要负责人决定
|
|
1186
|
+
用哪个 Key/额度跑,故先不动。
|
|
1187
|
+
- **P1-1** `agent.js` 拆分(`runTurn` ~1000 行)、**P1-2** 结构化摘要 + 关键事实钉死、
|
|
1188
|
+
**P1-3** 预算状态注入上下文、**P1-4** 出网闸门对子进程 curl 的补强、**P1-5** `docs/ARCHITECTURE.md` 重写。
|
|
1189
|
+
- P1-5 的**事实核对**(本文件记录用):`docs/ARCHITECTURE.md` 共 108 行,**确实**停留在 v0.1.x 口径——
|
|
1190
|
+
全文 0 次提到 `packs` / `constraints` / `ledger` / `net-policy` / `presets` / `model-caps`,
|
|
1191
|
+
第 7 节路线图列的多为已完成项。报告的这条批评**成立**。
|
|
1192
|
+
- P2 各项(Pack 沙箱档、成本归因下钻、降级目标自动选择、JSDoc 治理)与报告 §六 横向对比结论,
|
|
1193
|
+
仅作输入,不逐条排期。
|
|
1194
|
+
|
|
1195
|
+
### 3.40.1 发版现场另外发现的两处发布链路缺陷(v0.6.6 一并修)
|
|
1196
|
+
|
|
1197
|
+
这两条不在第三方报告里,是本次发版实操踩出来的,都属于"**门禁自己被骗**"这一形态:
|
|
1198
|
+
|
|
1199
|
+
| 位置 | 现象 | 根因 | 处置 |
|
|
1200
|
+
| --- | --- | --- | --- |
|
|
1201
|
+
| `MingDao-Harness-Site/scripts/harvest-release.mjs`、`scripts/update-downloads.mjs` | Release 附件明明已齐(`releases/<id>/assets` 列出 14 个),脚本却报「没有可采集的附件(是不是构建还没跑完?)」/「缺少安装包」 | `releases/tags/<tag>` 被 GitHub 的 **CDN 缓存**成"创建瞬间的空 assets"(Release 由工作流刚创建时) | 加 cache-buster;为空时按 release id 直连 assets 端点复核 |
|
|
1202
|
+
| `scripts/verify-release.mjs`("防半发布"最后一道闸) | 打出 `✓ GitHub/Release 存在(附件 0 个)`——把一个无法下载、无法收割的 Release 判成通过 | 同上(读了被缓存的空 assets),且**附件数为 0 不影响判定** | 同上修复,并把「GitHub 附件为 0」改判**失败**(Gitee/GitCode 按政策不附附件,故只对 GitHub 判) |
|
|
1203
|
+
| `MingDao-Harness-Site/scripts/harvest-release.mjs` | 线上 `/downloads/latest.yml` 自 v0.6.1 起再没更新过(v0.6.4/0.6.5 依次"跳过",无人察觉) | `latest*.yml` 是**不带版本号的指针文件**、体积都是几百字节,而幂等判据是"已存在且体积一致就跳过" | 指针类文件(`latest(-linux).yml`)一律覆盖——代价几百字节 |
|
|
1204
|
+
|
|
1205
|
+
**教训**:这一批四个问题(含第三方的三处)全是"**验证逻辑自身的判据太宽**"——只判"抛不抛错"、
|
|
1206
|
+
只判"体积一致"、只判"Release 存在"。**判据要对着"我们真正在意的那件事"写**:
|
|
1207
|
+
在意的不是"文件在不在",而是"它是这一个版本的、且能被下载"。
|
|
1208
|
+
|
|
1209
|
+
## 3.41 已修复(v0.6.7 批十二~批十五:三份 v0.6.6 审计报告的收口)
|
|
1210
|
+
|
|
1211
|
+
**来源**:三份第三方报告(全量代码审计 58 项 / 第六轮复审 / 安全与质量审计),共同把
|
|
1212
|
+
「**同一规则多份实现 → 最弱的那份说了算**」指为本项目安全缺陷的唯一共性根因。
|
|
1213
|
+
本批按它们的 v0.6.7 建议范围收口,逐条走「先复现 → 定级 → 修复 → 回归断言 → 变异验证」。
|
|
1214
|
+
|
|
1215
|
+
| 批 | 条目 | 报告出处 | 落地 |
|
|
1216
|
+
| --- | --- | --- | --- |
|
|
1217
|
+
| 十二 | **fetch 工具 SSRF 单一口径**:DNS 复检 fail-open、check/connect 两次解析(DNS rebinding)、元数据只按主机名判 | 报告一 C-1/H-1、报告二 P2-2、报告三 P1(三个报告的头号发现) | 新增 `src/ssrf-guard.js`(判定+钉扎单源);`runFetch` 整条走 `safeFetchText`;`tools/fetch.js` 只留工具契约并再导出同一函数对象。**行为级复现**:桩解析失败 → 旧路径放行、新路径 fail-closed;桩解析→127.0.0.1 + 不可解析主机名仍能连通(证明连接用的是**钉住的 IP**) |
|
|
1218
|
+
| 十二 | **出网闸门盲区**:`safe-fetch` 走 `node:http(s)`,完全绕过只钩 `globalThis.fetch` 的闸门 | 报告三 M1/M2 | net-guard 新增 `guardEgress()`(与 fetch 钩子共用同一入口),safe-fetch 每跳显式过闸+记账;**行为级**:block 档下 skill/registry/模型发现路径被拦且入账 |
|
|
1219
|
+
| 十二 | **`fetch(Request, init)` 丢失 Request 语义 → 静默降级成空 GET** | 报告二 P2-3 | 逐字段合并(method/headers/body/signal/duplex + 若干 init 字段),**行为级**:服务端收到 POST + body |
|
|
1220
|
+
| 十二 | **跨 origine 只剥三个头(黑名单)**:`x-api-key` 等自定义凭据头原样送给重定向目标 | 报告一 M-4 | 改**反向白名单**(`stripHeadersForCrossOrigin`);**行为级**:第二跳 `x-api-key`/`authorization` 已剥、`accept`/`user-agent` 保留 |
|
|
1221
|
+
| 十三 | **账本结局口径分裂**(返回值 capHit 与 runEnd 不一致、`'max_steps'` 死条件、截断方向反向) | 报告二 P2-1 | runTurn 内新增 `outcome` 单一来源,所有出口 `markOutcome()`,runEnd 与返回值同源;**行为级**:跑满步数时 `run.end.status==='capped' && capHit===true` 且与返回值一致 |
|
|
1222
|
+
| 十三 | **费用护栏读失败当 0**(statSync 过、readFileSync 失败 → 静默 ¥0) | 报告一 H-3 | 新增 `listCacheStatsStrict()`(区分 ENOENT 与读失败),护栏走严格读;**行为级(子进程)**:文件存在但 chmod 000 → `todayCost()` 返 **null** 而非 0,且告警一次 |
|
|
1223
|
+
| 十三 | **usage 缺失记 ¥0 → 三档护栏失明且无信号** | 报告一 H-4、登记 §3.39①(a) | agent 置 `usageUnknown` 并在有响应无 usage 时告警一次;`recordUsage` 记 `cost:null + usageUnknown`;账本 cost 事件带 `usageUnknown`;护栏给出"今日 N 次用量未知"提示 |
|
|
1224
|
+
| 十三 | **Batch 失败/取消路径漏账**、**Batch 不查护栏** | 报告一 M-10、登记 §3.39①(c) | 四条失败路径记「用量未知」;提交前 `checkCostGuard`(block 档拒绝提交,**服务端零请求**——行为级断言) |
|
|
1225
|
+
| 十三 | **downgrade 不看在途费用**(降级成免检通道) | 登记 §3.39①(b) | 降级换模型后按在途费用复查,仍超限即停(行为级:`providerCalls === 0`) |
|
|
1226
|
+
| 十三 | `run.start.packs` 恒为空数组(取不存在的键) | 报告二 P3-1 | 改取 `mounted.map(p => p.name)`;行为级:挂载 Pack 后账本里能看到它 |
|
|
1227
|
+
| 十三 | capHit 口径:Web 标 `done`、worker 标 `failed` | 报告二小项 | 统一为「不算完成」:Web 侧 `'capped'`(前端给明确横幅),worker 维持 `'failed'` |
|
|
1228
|
+
| 十四 | **脱敏器不认带引号键**(`{"api_key": …}` / `{'api_key': …}` 明文进审计与诊断包) | 报告一 H-2、登记 §3.39② | 正则允许成对引号包裹键名/值;**测试刻意用无前缀密钥值**(`sk-` 被另一条规则掩掉,拿它测会假绿——变异验证当场指出) |
|
|
1229
|
+
| 十四 | **凭证写用宽松读**(损坏时一次 init/WebUI 设 Key 清空其余凭据) | 报告一 M-1(H-8 只修了 key 命令层) | `setStoredKey`/`removeStoredKey` 内置严格读、损坏即拒绝写;三个 WebUI 调用点 + init 向导把失败说出来 |
|
|
1230
|
+
| 十四 | **deny 拆段漏命令替换/子 shell/`\r`/续行**;`denyStrict` 被 `sh -c` 绕过 | 报告一 M-2、报告二 P3-6 | 分隔面补全(fail-closed 方向)+ denyStrict 下包装执行直接拒绝并给可操作指引 |
|
|
1231
|
+
| 十四 | **bash env 词表漏 `key` 段**(`OPENAI_KEY`/`GITHUB_KEY` 原样透传子进程) | 报告一 M-3 | 与脱敏器共用 `ENV_SECRET_SEGMENTS`(唯一例外 `pwd`,避免剥离 `PWD`) |
|
|
1232
|
+
| 十四 | **constraints ReDoS 判定弱于 grep**(`(a|aa)+`/`((a|ab)x)+` 装载即通过) | 报告一 M-5 | 新增 `src/regex-safety.js` 作为单一来源(嵌套量词 + 歧义分支,含嵌套组),grep/constraints/packs 三处共用;刻意不拦 `(a+)?`(`?` 只重复 0/1 次,不会爆炸) |
|
|
1233
|
+
| 十四 | `workspaces set` 存**未归一化**路径 → 改链指向即围栏塌陷 | 报告一 H-5、登记 P1-21 | 存 `t2`(realpathDeep 结果),与同文件 add 分支对齐 |
|
|
1234
|
+
| 十五 | `redactDeep` 对象键数无界、`/memory add` 裸 appendFileSync(0644/无日期戳)、worker 的 ask 漏对象形态、`pack new` 写进非发现路径、自定义 Provider 模块按 `Date.now()` 无限重载、`clampText` 裸 slice 切代理对、schedule 锁内做进程调用、share-accept 文件写在锁外且非原子、会话 id 同秒碰撞 | 报告一 L-1/L-10/M-6/M-7/M-13、报告二 P3-3/P3-4/P3-5/P3-7 | 逐项落地;**测试基建**:新增 `test/mutate/`(变异验证脚本入库,报告一 S-3② 的欠账——此前"7/7"是手工过程、谁也复现不了) |
|
|
1235
|
+
|
|
1236
|
+
**变异验证(本批累计 38 条)**:`node test/mutate/run.mjs`
|
|
1237
|
+
(批十二 8/8、批十三 10/10、批十四·十五 10/10,另有 v0.6.5/v0.6.6 的 4+7 条历史条目)。
|
|
1238
|
+
过程中被抓出 **3 处假绿**(断言第一版抓不到变异):
|
|
1239
|
+
① 用 `sk-…` 测"带引号的键"——它被前缀规则掩掉了,测不到 H-2;
|
|
1240
|
+
② M-13 两次调用落在同一毫秒 → 变异体与修复版表现一致;
|
|
1241
|
+
③ 变异验证脚本自身的关键词对不上(失败的断言不是要钉的那条)。
|
|
1242
|
+
三条都按"收紧后重验才算数"处理。
|
|
1243
|
+
|
|
1244
|
+
**未纳入本批(如实登记,按报告 §五/§八 排期)**:
|
|
1245
|
+
- 报告一 **M-8**(sync-server 设备级吊销 / push 配额与限流)、**M-9**(memory 子系统 4 处锁外读-改-写)、
|
|
1246
|
+
**M-11**(逐轮回调计价锚点——本批已随 H-4 一并把 `perf.requestStartAt` 透到 recordUsage,逐轮路径同源)、
|
|
1247
|
+
**L-2/L-4/L-5/L-12/L-15/L-16/L-17** 等低危项;
|
|
1248
|
+
- 报告一 **P1-1**(agent.js 拆分,runTurn ~1057 行)、**P1-2**(结构化摘要 + 关键事实钉死)、
|
|
1249
|
+
**P1-3**(预算状态注入上下文)、**P1-4**(出网闸门对 bash curl 的补强)、**P1-5**(ARCHITECTURE.md 重建)、
|
|
1250
|
+
报告二 P3-2 的消费方、报告三 §4 阶段二/三(对抗测试集扩面、容器 CI job、关键模块 TS 迁移、发布状态机);
|
|
1251
|
+
- 报告一 **H-6/L-18**(自更新多镜像取最大版本、install.sh 无校验和)——供应链完整性锚需要发布流程配合,
|
|
1252
|
+
与 `ledger --sign-key`、在线缓存基准(P0-2)同列为 v1.0 前项。
|
|
@@ -61,7 +61,7 @@ export MINGDAO_HOME=$(mktemp -d) # 绝不动真实 ~/.mingdao
|
|
|
61
61
|
|
|
62
62
|
npm run typecheck # 期望 0 错误
|
|
63
63
|
npm run typecheck:strict # 期望 当前 0 / 基线 0
|
|
64
|
-
node test/smoke.js # 期望 全部通过(当前
|
|
64
|
+
node test/smoke.js # 期望 全部通过(当前 156 组断言)
|
|
65
65
|
node test/e2e-local.js # 期望 全通过
|
|
66
66
|
node test/e2e-web.js # 期望 全通过
|
|
67
67
|
node test/e2e-schedule.js # 期望 全通过
|
|
@@ -75,6 +75,34 @@ node src/cli.js diagnose # 自检报告(脱敏)
|
|
|
75
75
|
- [ ] `RELEASE-NOTES-<版本>.md` 已写好(三平台正文共用)
|
|
76
76
|
- [ ] `node src/cli.js --version` 与版本号一致
|
|
77
77
|
- [ ] 工作区干净(`git status` 无未提交改动),且已推到 `origin/main`
|
|
78
|
+
- [ ] **在标准 Windows 账户(未开 Developer Mode / 非管理员)上 `npm test` 全绿**(见 §1.3)
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
### 1.3 Windows 实机验收(必做,2026-09-23 新增)
|
|
83
|
+
|
|
84
|
+
**CI 五腿全绿 ≠ Windows 上真的能跑。** 两次真实事故都源于"CI runner 的权限/环境掩盖平台差异":
|
|
85
|
+
|
|
86
|
+
| 事故 | 现象 | CI 为什么是绿的 |
|
|
87
|
+
| --- | --- | --- |
|
|
88
|
+
| v0.2.5 报告(e2e-web 红灯) | `%TEMP%` ⊂ `%USERPROFILE%` 导致工作目录围栏误判 | runner 的临时目录与家目录关系与普通账户不同(还叠了 8.3 短名侥幸) |
|
|
89
|
+
| v0.6.5 第三方评估 §4.1 / §4.3 | 标准账户下 `smoke` 崩在软链用例、`run-all` 因 `spawn('.cmd')` 同步抛 EINVAL 而崩溃(bench 的 214 条断言**从不执行**) | runner 以管理员运行,能建真符号链接;且 `npm run coverage`(run-all 的唯一入口)**只在 Linux + Node 20 那条腿上跑** |
|
|
90
|
+
|
|
91
|
+
因此把下面这条列进验收(**光靠 CI 绿不足以证明**):
|
|
92
|
+
|
|
93
|
+
```powershell
|
|
94
|
+
# 在 Windows 11 的**普通账户**(未开 Developer Mode)上,仓库根目录:
|
|
95
|
+
npm ci
|
|
96
|
+
npm test # = node test/run-all.mjs:六套套件 + 汇总表,必须全绿且有汇总表
|
|
97
|
+
npm run typecheck # 0 错误
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
要点:
|
|
101
|
+
- `npm test` 会跑 **bench 五套(214 断言,含省钱基准回归)**——这条在 Windows 上曾经是静默空转的;
|
|
102
|
+
- 软链相关用例在标准账户上应**显式打印「⚠ 跳过」**而不是崩溃(能力探测见 `test/smoke.js` 的
|
|
103
|
+
`makeRealSymlink`;围栏判据本身由 `test/smoke.js` §124 的**桩 fs 单测**覆盖,与平台权限解耦);
|
|
104
|
+
- 若出现红色,先看是不是"平台能力差异被当成失败",但**不要**用"加个 catch 跳过"了事:
|
|
105
|
+
与安全边界相关的(围栏、路径、凭据)必须补平台无关的桩测试,见 §124 的做法。
|
|
78
106
|
|
|
79
107
|
---
|
|
80
108
|
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { trimMessages, clampText, messageTokens, approxTokens } from './context.js';
|
|
6
6
|
import { compactConversation } from './compact.js';
|
|
7
7
|
import { buildToolSchemas, dispatch } from './tools/index.js';
|
|
8
|
-
import { modelPreset } from './models.js';
|
|
8
|
+
import { DEFAULT_MODEL, modelPreset } from './models.js';
|
|
9
9
|
import { resolveModelCaps, safeBudget, EDGE_RATIO } from './model-caps.js';
|
|
10
10
|
import { makeTokenCounter } from './tokenizer.js';
|
|
11
11
|
import { createHooks } from './hooks.js';
|
|
@@ -383,6 +383,22 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
383
383
|
/** v0.6.3(M-16):本回合是否出现过"上游提前关流"(回答可能不完整)。
|
|
384
384
|
* 刻意不叫 truncated——既有 `truncated` 的含义是"步数兜底总结也失败了",两者必须分开。 */
|
|
385
385
|
let upstreamTruncated = false;
|
|
386
|
+
// v0.6.7(报告二 P2-1):**结局判定的单一来源**。
|
|
387
|
+
// 此前账本 runEnd 在 finally 里拿不到返回值,只能用局部变量 `finish` 重算结局,于是四处口径分裂:
|
|
388
|
+
// ① 跑满步数:返回值 capHit:true,账本却记 status='done'、capHit=false;
|
|
389
|
+
// ② `finish === 'max_steps'` 是**死条件**(全文件无人赋该值,析取支永不成立);
|
|
390
|
+
// ③ 步数耗尽的另一条分支(末轮仍有正文+工具调用)返回时**根本没有 capHit 字段**,
|
|
391
|
+
// 与同一 if 的 `break` 分支(capHit:true)自相矛盾;
|
|
392
|
+
// ④ `finish === 'length'` 时账本记 capHit:true,而循环跑满的返回值是 truncated:true —— 同一次运行两个说法。
|
|
393
|
+
// 现在:所有出口都调用 markOutcome 写进同一个 outcome,返回值与 runEnd 都从它取。
|
|
394
|
+
/** @type {{status: string, capHit: boolean, truncated: boolean, aborted: boolean, upstreamTruncated: boolean, usageUnknown: boolean}} */
|
|
395
|
+
const outcome = { status: 'done', capHit: false, truncated: false, aborted: false, upstreamTruncated: false, usageUnknown: false };
|
|
396
|
+
/** 收敛本回合结局(后写覆盖,便于"先默认、后升级")。
|
|
397
|
+
* @param {string} status @param {Partial<{capHit: boolean, truncated: boolean, aborted: boolean}>} [extra] */
|
|
398
|
+
const markOutcome = (status, extra = {}) => {
|
|
399
|
+
outcome.status = status;
|
|
400
|
+
Object.assign(outcome, extra);
|
|
401
|
+
};
|
|
386
402
|
// v0.3.1 自动续跑(长程执行):跑满 stepLimit 步后不再直接中断,而是注入进度摘要再续跑,
|
|
387
403
|
// 最多 maxRounds 轮(默认 3,可用 cfg.maxRounds 调);审计/重构等大任务不再「一步中断」。
|
|
388
404
|
const maxRounds = Math.max(1, Number(cfg.maxRounds) || 3);
|
|
@@ -406,7 +422,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
406
422
|
cwd: workingDir,
|
|
407
423
|
permission: cfg.permission ?? permission?.mode ?? null,
|
|
408
424
|
preset: preset?.name ?? cfg.preset ?? null,
|
|
409
|
-
|
|
425
|
+
// v0.6.7(报告二 P3-1):activeCtx 的形状是 { mounted, warnings, promptSections, constraints, toolCount },
|
|
426
|
+
// **没有 `.packs` 键** —— 这里此前恒为 [],账本里"本次运行挂了哪些 Pack"永远记空。
|
|
427
|
+
packs: (getActivePackContext()?.mounted || []).map((/** @type {any} */ p) => p?.name).filter(Boolean),
|
|
410
428
|
});
|
|
411
429
|
// 回合性能指标(状态栏:LLM 时长 / 工具时长 / 首 token 延迟 / 步数)
|
|
412
430
|
let llmMsTotal = 0;
|
|
@@ -430,6 +448,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
430
448
|
usedModel: activeModel, // 省钱 B4:本回合实际使用模型(降级后归属它)
|
|
431
449
|
requestStartAt: lastRequestStartAt, // v0.4.6:峰谷计价锚点(请求发起时刻,非落账时刻)
|
|
432
450
|
deliverables: [...deliverables], // v0.3.1:CLI/REPL 续跑检查点复用(此前 artifacts 恒空)
|
|
451
|
+
// v0.6.7(报告一 H-4):本回合是否出现过"模型有响应但服务端没回 usage"
|
|
452
|
+
usageUnknown: outcome.usageUnknown,
|
|
433
453
|
});
|
|
434
454
|
let aborted = false;
|
|
435
455
|
let emptyRounds = 0; // 连续空/截断输出计数(防止无限续写)
|
|
@@ -675,7 +695,26 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
675
695
|
// 费用护栏(A2/B4):每轮开始前按今日实际费用检查;block 暂停本轮;
|
|
676
696
|
// downgrade 自动切换便宜模型继续执行(每回合只切一次,切换即粘滞)
|
|
677
697
|
if (cfg.costGuard) {
|
|
678
|
-
|
|
698
|
+
let guard = checkCostGuard(activeModel);
|
|
699
|
+
// v0.6.7(登记 §3.39①(b),报告一 §2.5 复核「仍成立」):**在途费用必须参与降级决策**。
|
|
700
|
+
// 此前 downgrade 档只看**落账**(checkCostGuard → todayCost),而单看落账要在回合结束后才更新——
|
|
701
|
+
// 于是长回合里贵模型会一直用到跑完,实测单回合可超日限 9.1×(note=null,用户毫无察觉)。
|
|
702
|
+
// 现在:若「今日已用 + 本回合在途」已越线,且当前还不在最便宜档,就**当场触发降级**
|
|
703
|
+
// (不否决降级本身——那等于把这个功能废掉;只是把它触发得更早)。
|
|
704
|
+
if (!guard && !downgraded) {
|
|
705
|
+
const g0 = costGuardConfig();
|
|
706
|
+
if (g0 && String(g0.action) === 'downgrade' && Number(g0.dailyLimitYuan) > 0) {
|
|
707
|
+
const usedNow = usedTodayWithInflight();
|
|
708
|
+
if (usedNow != null && usedNow >= Number(g0.dailyLimitYuan)) {
|
|
709
|
+
guard = {
|
|
710
|
+
blocked: false,
|
|
711
|
+
downgrade: true,
|
|
712
|
+
downgradeModel: String(g0.downgradeModel || DEFAULT_MODEL),
|
|
713
|
+
message: `⚠ 费用护栏:今日已用(含本回合在途)≈¥${usedNow.toFixed(4)} 已达上限 ¥${Number(g0.dailyLimitYuan).toFixed(2)}——已自动降级到便宜模型继续执行。`,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
679
718
|
if (guard) {
|
|
680
719
|
if (guard.blocked) {
|
|
681
720
|
stripOrphanCalls();
|
|
@@ -765,13 +804,15 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
765
804
|
io.endTurn();
|
|
766
805
|
if (aborted) {
|
|
767
806
|
stripOrphanCalls();
|
|
768
|
-
|
|
807
|
+
markOutcome('aborted', { aborted: true });
|
|
808
|
+
return { text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: outcome.aborted, upstreamTruncated, durationMs: Date.now() - startedAt, perf: perf() };
|
|
769
809
|
}
|
|
770
810
|
// MacBook 本地 507 memory_refusal 根因(v0.4.5):服务端内存拒绝不是「空输出」——
|
|
771
811
|
// 直接终结合合并透出降级提示,不计入空轮、不注入续写重试(内存未释放必再 507,空烧请求)。
|
|
772
812
|
const e = /** @type {any} */ (err);
|
|
773
813
|
if (e?.status === 507 || /memory_refusal|内存不足|内存拒绝/i.test(String(e?.message || ''))) {
|
|
774
814
|
stripOrphanCalls();
|
|
815
|
+
markOutcome('done');
|
|
775
816
|
return {
|
|
776
817
|
text: null,
|
|
777
818
|
reasoning: '',
|
|
@@ -780,6 +821,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
780
821
|
finish,
|
|
781
822
|
truncated: false,
|
|
782
823
|
aborted: false,
|
|
824
|
+
upstreamTruncated,
|
|
783
825
|
note: '本地模型内存不足(507 memory_refusal)——请压缩上下文(减小 config.contextBudget 或 /compact)、减少并发子任务,或重启模型服务释放内存后再继续。',
|
|
784
826
|
durationMs: Date.now() - startedAt,
|
|
785
827
|
perf: perf(),
|
|
@@ -812,6 +854,23 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
812
854
|
});
|
|
813
855
|
// 省钱 B1:只读阶段中模型文字明确表达写意图 → 下一轮注入全量工具(多一轮,几乎无感)
|
|
814
856
|
if (readOnlyPhase && hasWriteIntent(res.text)) readOnlyPhase = false;
|
|
857
|
+
if (!res.usage && (res.text || res.toolCalls?.length)) {
|
|
858
|
+
// v0.6.7(报告一 H-4):网关/自定义端点不返回 usage 时,此前这回合在账上**不存在**——
|
|
859
|
+
// warn/block/downgrade 三档护栏全部失明且没有任何信号。这里置位 + 明确告知,
|
|
860
|
+
// 落账时按"用量未知"处理(cost:null / priced:false),绝不冒充"这次花了 ¥0"。
|
|
861
|
+
if (!outcome.usageUnknown) {
|
|
862
|
+
outcome.usageUnknown = true;
|
|
863
|
+
try {
|
|
864
|
+
io.print(
|
|
865
|
+
style(
|
|
866
|
+
'⚠ 服务端未返回本次调用的用量(usage 缺失):这一回合无法计费,今日费用护栏会少计这部分消费。\n' +
|
|
867
|
+
' 常见于自建网关/中转端点;如需精确护栏,请让网关透传 usage 字段。',
|
|
868
|
+
C.yellow
|
|
869
|
+
)
|
|
870
|
+
);
|
|
871
|
+
} catch {}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
815
874
|
if (res.usage) {
|
|
816
875
|
usage.prompt_tokens += res.usage.prompt_tokens || 0;
|
|
817
876
|
usage.completion_tokens += res.usage.completion_tokens || 0;
|
|
@@ -842,7 +901,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
842
901
|
if (res.text) {
|
|
843
902
|
const ap = await applyOutputConstraints(res.text, { messages, provider, activeModel, temperature, maxOutput, usage, signal: currentAc?.signal });
|
|
844
903
|
messages.push({ role: 'assistant', content: ap.text });
|
|
845
|
-
|
|
904
|
+
// 步数已耗尽:这一条与下方 `break`(→ 兜底总结/截断收尾)必须是同一口径
|
|
905
|
+
markOutcome('capped', { capHit: true });
|
|
906
|
+
return { text: ap.text, reasoning: res.reasoning || '', usage, steps, finish, truncated: false, aborted: false, capHit: outcome.capHit, upstreamTruncated, note: ap.note || undefined, durationMs: Date.now() - startedAt, perf: perf() };
|
|
846
907
|
}
|
|
847
908
|
break;
|
|
848
909
|
}
|
|
@@ -1203,6 +1264,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1203
1264
|
// 静默空输出(无工具无正文):同样回填续写提示,避免界面"没动静"(审计 Q1:与截断续写统一护栏)
|
|
1204
1265
|
emptyRounds += 1;
|
|
1205
1266
|
if (emptyRounds >= maxEmptyRounds) {
|
|
1267
|
+
markOutcome('done');
|
|
1206
1268
|
return {
|
|
1207
1269
|
text: null,
|
|
1208
1270
|
reasoning: res.reasoning || '',
|
|
@@ -1211,6 +1273,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1211
1273
|
finish,
|
|
1212
1274
|
truncated: false,
|
|
1213
1275
|
aborted: false,
|
|
1276
|
+
upstreamTruncated,
|
|
1214
1277
|
note: '模型本轮没有输出正文。',
|
|
1215
1278
|
durationMs: Date.now() - startedAt,
|
|
1216
1279
|
perf: perf(),
|
|
@@ -1239,14 +1302,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1239
1302
|
});
|
|
1240
1303
|
continue;
|
|
1241
1304
|
}
|
|
1305
|
+
markOutcome(finish === 'length' ? 'capped' : 'done', { capHit: false, truncated: finish === 'length' });
|
|
1242
1306
|
return {
|
|
1243
1307
|
text: finalText,
|
|
1244
1308
|
reasoning: res.reasoning || '',
|
|
1245
1309
|
usage,
|
|
1246
1310
|
steps,
|
|
1247
1311
|
finish,
|
|
1248
|
-
truncated:
|
|
1312
|
+
truncated: outcome.truncated,
|
|
1249
1313
|
aborted: false,
|
|
1314
|
+
upstreamTruncated,
|
|
1250
1315
|
note: outputNote || undefined,
|
|
1251
1316
|
durationMs: Date.now() - startedAt,
|
|
1252
1317
|
perf: perf(),
|
|
@@ -1352,16 +1417,19 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1352
1417
|
: { text: null, note: null };
|
|
1353
1418
|
if (wrapApplied.text) messages.push({ role: 'assistant', content: wrapApplied.text });
|
|
1354
1419
|
// capHit:本轮因步数上限被迫收尾(任务可能未真正完成)→ 供上层落检查点续跑
|
|
1355
|
-
|
|
1420
|
+
markOutcome('capped', { capHit: true });
|
|
1421
|
+
return { text: wrapApplied.text || null, reasoning: wrapRes.reasoning || '', usage, steps, finish, truncated: false, aborted: false, capHit: outcome.capHit, upstreamTruncated, note: wrapApplied.note || undefined, durationMs: Date.now() - startedAt, perf: perf() };
|
|
1356
1422
|
} catch (/** @type {any} */ err) {
|
|
1357
1423
|
// v0.4.1:不再静默吞异常——总结失败原因透出,便于定位(此前用户只见「输出截断/无反馈」)
|
|
1358
1424
|
try { io.print(style(`⚠ 兜底总结失败:${String(err?.message || err)}`, C.yellow)); } catch {}
|
|
1359
1425
|
}
|
|
1360
1426
|
}
|
|
1361
|
-
|
|
1427
|
+
markOutcome('capped', { capHit: true, truncated: true });
|
|
1428
|
+
return { text: null, reasoning: '', usage, steps, finish, truncated: outcome.truncated, aborted: false, capHit: outcome.capHit, upstreamTruncated, durationMs: Date.now() - startedAt, perf: perf() };
|
|
1362
1429
|
}
|
|
1363
1430
|
// 理论不可达(for 循环末轮必 return);给 tsc 一个兜底,保证 runTurn 恒有返回值
|
|
1364
|
-
|
|
1431
|
+
markOutcome('capped', { capHit: true, truncated: true });
|
|
1432
|
+
return { text: null, reasoning: '', usage, steps, finish, truncated: outcome.truncated, aborted: false, capHit: outcome.capHit, upstreamTruncated, durationMs: Date.now() - startedAt, perf: perf() };
|
|
1365
1433
|
} catch (/** @type {any} */ err) {
|
|
1366
1434
|
// 审计 P2-6(v0.4.2):工具管线异常(hooks.pre / permission.check / prepTool 等)沿大 try 上抛时,
|
|
1367
1435
|
// assistant tool_calls 已 push 进 messages 却无对应 tool 回填——会话恢复后 API 因孤儿 tool_call_id 400。
|
|
@@ -1383,7 +1451,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1383
1451
|
// 两者单价不同(flash 便宜数倍),于是账本与日费用护栏系统性偏移,
|
|
1384
1452
|
// 而"降级省钱"恰恰是靠这里体现的。
|
|
1385
1453
|
const yuan = estimateCost(activeModel, usage.prompt_tokens, usage.completion_tokens, cacheSplit(usage), costDate);
|
|
1386
|
-
|
|
1454
|
+
// v0.6.7(H-4):用量未知时哪怕 0/0 也算出一个真 0(estimateCost(0,0)===0),
|
|
1455
|
+
// 那会把"没记账"伪装成"没花钱"。未知一律 priced:false + yuan:null。
|
|
1456
|
+
const usageKnown = !outcome.usageUnknown && (usage.prompt_tokens > 0 || usage.completion_tokens > 0 || steps === 0);
|
|
1457
|
+
const priced = usageKnown && typeof yuan === 'number' && Number.isFinite(yuan);
|
|
1387
1458
|
turnLedger.cost({
|
|
1388
1459
|
// BUG-024:归属同样要用实际计费的模型(run.start 里已记过"用户请求的模型",
|
|
1389
1460
|
// 这里再写 modelName 会让"花了钱的那次调用"归属到一个没被调用的模型)
|
|
@@ -1391,6 +1462,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1391
1462
|
usage,
|
|
1392
1463
|
yuan: priced ? yuan : null,
|
|
1393
1464
|
priced,
|
|
1465
|
+
// v0.6.7(H-4):把"用量未知"作为显式事实落账,事后可区分「没花钱」与「没记账」
|
|
1466
|
+
...(usageKnown ? {} : { usageUnknown: true }),
|
|
1394
1467
|
pricing: { requestStartAt: lastRequestStartAt, peak: isPeakHour(costDate) },
|
|
1395
1468
|
});
|
|
1396
1469
|
turnLedger.runEnd({
|
|
@@ -1398,15 +1471,17 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1398
1471
|
// status 是「这次运行怎么结束的」,与模型层的 finish_reason(stop/tool_calls/length)
|
|
1399
1472
|
// 不是一回事:前者给人看账本,后者记在 model.round 里。混用会让「status=stop」这种
|
|
1400
1473
|
// 记录无法回答「这次到底完成了没有」。
|
|
1401
|
-
|
|
1474
|
+
// v0.6.7(P2-1):一律取自 outcome —— 与返回值**同一个来源**,不可能再分裂
|
|
1475
|
+
status: outcome.status,
|
|
1402
1476
|
steps,
|
|
1403
1477
|
rounds: round + 1,
|
|
1404
1478
|
yuanTotal: priced ? yuan : null,
|
|
1405
1479
|
priced,
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1480
|
+
capHit: outcome.capHit,
|
|
1481
|
+
truncated: outcome.truncated,
|
|
1482
|
+
aborted: outcome.aborted,
|
|
1483
|
+
// v0.6.7(P3-2):上游提前关流此前只置位、不落账("检测到了、传递断了")
|
|
1484
|
+
upstreamTruncated: Boolean(upstreamTruncated || outcome.upstreamTruncated),
|
|
1410
1485
|
});
|
|
1411
1486
|
// v0.6.2(B-WS-1/2):记账降级必须让用户看见。
|
|
1412
1487
|
// 此前写账本失败只把 alive 置 false,用户拿到一段「正常」总结却不知道这次运行
|
package/src/batch.js
CHANGED
|
@@ -11,6 +11,7 @@ import path from 'node:path';
|
|
|
11
11
|
import crypto from 'node:crypto';
|
|
12
12
|
import { resolveProviderConfig } from './providers/index.js';
|
|
13
13
|
import { estimateBatchCost, BATCH_DISCOUNT } from './pricing.js';
|
|
14
|
+
import { checkCostGuard } from './cost-guard.js';
|
|
14
15
|
import { recordCacheStats } from './cachestats.js';
|
|
15
16
|
import { approxTokens } from './context.js';
|
|
16
17
|
|
|
@@ -223,6 +224,18 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
|
|
|
223
224
|
}
|
|
224
225
|
}
|
|
225
226
|
|
|
227
|
+
// —— v0.6.7(登记 §3.39①(c)):Batch 也要过费用护栏 ——
|
|
228
|
+
// 此前 Batch 通道**完全不查 costGuard**(只认用户显式给的 --max-cost):一次提交几百条问题,
|
|
229
|
+
// 恰恰是"烧穿日限"最省事的路径,而护栏在交互通道上却是硬的。这里与交互通道同一口径:
|
|
230
|
+
// block 档直接拒绝提交,warn/downgrade 档把护栏的话转达给调用方(不静默)。
|
|
231
|
+
{
|
|
232
|
+
const guard = checkCostGuard(model);
|
|
233
|
+
if (guard) {
|
|
234
|
+
if (guard.blocked) return { error: `费用护栏拦截,未提交批处理:${guard.message}` };
|
|
235
|
+
onStatus?.(guard.message);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
226
239
|
// —— 省钱 B2:--max-cost 预算上限(提交前估算拦截)——
|
|
227
240
|
let estimatedCost = /** @type {number|null} */ (0);
|
|
228
241
|
if (maxCost > 0) {
|
|
@@ -264,6 +277,34 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
|
|
|
264
277
|
)
|
|
265
278
|
.join('\n') + '\n';
|
|
266
279
|
|
|
280
|
+
/**
|
|
281
|
+
* 失败/取消路径的**最少诚实记账**(v0.6.7 / 报告一 M-10)。
|
|
282
|
+
* 批次一旦提交,服务端就可能已经处理并计费,而此前这四条路径(本地取消 / 超 24h / failed /
|
|
283
|
+
* expired·cancelled)本地**一条记录都不写**——账单在涨、账上却没有这笔。
|
|
284
|
+
* 拿不到 token 数就如实记「用量未知」(cost:null + usageUnknown + 服务端已处理条数),
|
|
285
|
+
* 让今日护栏能说出"有 N 次未知消费",而不是把未知记成 0。
|
|
286
|
+
* @param {any} j 最近一次批次状态(可能为 null)
|
|
287
|
+
* @param {string} reason
|
|
288
|
+
*/
|
|
289
|
+
const recordUnaccountedBatch = (j, reason) => {
|
|
290
|
+
try {
|
|
291
|
+
const rc = j?.request_counts || {};
|
|
292
|
+
const done = rc.completed != null ? `${rc.completed}/${rc.total ?? '?'}` : '未知';
|
|
293
|
+
recordCacheStats({
|
|
294
|
+
model,
|
|
295
|
+
prompt: 0,
|
|
296
|
+
completion: 0,
|
|
297
|
+
hit: null,
|
|
298
|
+
miss: null,
|
|
299
|
+
cost: null, // 未知:绝不写 0(0 会被读成"没花钱")
|
|
300
|
+
saved: null,
|
|
301
|
+
batch: true,
|
|
302
|
+
usageUnknown: true,
|
|
303
|
+
note: `batch ${reason}(服务端已处理 ${done} 条,用量未知)`,
|
|
304
|
+
});
|
|
305
|
+
} catch {}
|
|
306
|
+
};
|
|
307
|
+
|
|
267
308
|
try {
|
|
268
309
|
onStatus?.('上传输入文件…');
|
|
269
310
|
const fileId = await uploadFile(base, apiKey, jsonl, signal);
|
|
@@ -286,6 +327,7 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
|
|
|
286
327
|
if (signal?.aborted) {
|
|
287
328
|
// 本地取消必须尽力转化为服务端取消,否则用户以为停了、账单照涨
|
|
288
329
|
const cancelled = await cancelServerBatch(base, apiKey, batch.id);
|
|
330
|
+
recordUnaccountedBatch(null, '本地取消');
|
|
289
331
|
return {
|
|
290
332
|
error: cancelled
|
|
291
333
|
? '已取消(已请求服务端停止该批次)'
|
|
@@ -297,6 +339,7 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
|
|
|
297
339
|
if (Date.now() - t0 > 24 * 3600 * 1000) {
|
|
298
340
|
// 超窗口同样要尝试停掉,避免留下一个无人接管却在计费的批次
|
|
299
341
|
const cancelled = await cancelServerBatch(base, apiKey, batch.id);
|
|
342
|
+
recordUnaccountedBatch(null, '超 24h 窗口');
|
|
300
343
|
return {
|
|
301
344
|
error: `批处理超过 24h 窗口${cancelled ? '(已请求服务端停止)' : '(服务端取消失败,批次可能仍在计费)'}`,
|
|
302
345
|
batchId: batch.id,
|
|
@@ -309,7 +352,10 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
|
|
|
309
352
|
failures = 0;
|
|
310
353
|
} catch (err) {
|
|
311
354
|
failures += 1;
|
|
312
|
-
if (failures >= 10)
|
|
355
|
+
if (failures >= 10) {
|
|
356
|
+
recordUnaccountedBatch(null, '轮询连续失败');
|
|
357
|
+
return { error: `轮询失败:${(/** @type {any} */ (err))?.message || err}(任务仍在服务端,ID ${batch.id})`, batchId: batch.id };
|
|
358
|
+
}
|
|
313
359
|
await new Promise((r) => setTimeout(r, Math.min(baseInterval * 1.5 ** failures, 30000)));
|
|
314
360
|
continue;
|
|
315
361
|
}
|
|
@@ -320,6 +366,7 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
|
|
|
320
366
|
}
|
|
321
367
|
if (['failed', 'expired', 'cancelled', 'canceled'].includes(st)) {
|
|
322
368
|
const detail = j?.errors?.data?.[0]?.message || j?.errors?.message || '';
|
|
369
|
+
recordUnaccountedBatch(j, `服务端状态 ${st}`);
|
|
323
370
|
return { error: `批处理失败:${st}${detail ? '(' + detail + ')' : ''}`, batchId: batch.id };
|
|
324
371
|
}
|
|
325
372
|
polls += 1;
|
|
@@ -351,12 +398,18 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
|
|
|
351
398
|
if (!outputs[i]) outputs[i] = { id: String(i), content: '' };
|
|
352
399
|
}
|
|
353
400
|
const usage = { prompt_tokens: prompt, completion_tokens: completion };
|
|
354
|
-
|
|
401
|
+
// v0.6.7(H-4 的 batch 变体):结果里没有 usage(网关不回 / 全部条目失败)时,
|
|
402
|
+
// prompt+completion 恒为 0 —— 那不是"免费",而是"没有用量数据",必须区分。
|
|
403
|
+
const usageUnknown = prompt === 0 && completion === 0;
|
|
404
|
+
const cost = usageUnknown ? null : estimateBatchCost(model, prompt, completion);
|
|
355
405
|
const outFile = path.join(workingDir, `mingdao-batch-result-${Date.now()}.jsonl`);
|
|
356
406
|
fs.writeFileSync(outFile, outputs.map((o) => JSON.stringify(o)).join('\n') + '\n');
|
|
357
|
-
recordCacheStats({
|
|
407
|
+
recordCacheStats({
|
|
408
|
+
model, prompt, completion, hit: null, miss: null, cost, saved: null, batch: true,
|
|
409
|
+
...(usageUnknown ? { usageUnknown: true, note: 'batch 结果未返回 usage(用量未知)' } : {}),
|
|
410
|
+
});
|
|
358
411
|
onStatus?.(`完成:${outputs.length} 条结果${deduped ? `(去重合并 ${deduped} 条)` : ''}`);
|
|
359
|
-
return { ok: true, batchId: batch.id, outputFile: outFile, results: outputs, usage, cost, discount: BATCH_DISCOUNT, deduped, estimatedCost };
|
|
412
|
+
return { ok: true, batchId: batch.id, outputFile: outFile, results: outputs, usage, cost, usageUnknown, discount: BATCH_DISCOUNT, deduped, estimatedCost };
|
|
360
413
|
} catch (err) {
|
|
361
414
|
// 批处理端点不支持(404/405 等)→ 明确告知,不静默
|
|
362
415
|
return { error: `批处理不可用:${(/** @type {any} */ (err))?.message || err}(该服务商可能不支持 Batch API,可用 config.batchBaseUrl 指定支持的网关)` };
|
package/src/cachestats.js
CHANGED
|
@@ -68,6 +68,11 @@ export function recordCacheStats(/** @type {any} */ entry) {
|
|
|
68
68
|
// 这些调用不在 agent 回合 usage 里,单独入账,不与回合级重复计费。
|
|
69
69
|
aux: entry.aux === true ? true : undefined,
|
|
70
70
|
auxReason: entry.auxReason ? String(entry.auxReason) : undefined,
|
|
71
|
+
// v0.6.7(报告一 H-4 / M-10):这笔消费**用量未知**(服务端没回 usage、或批次在拿不到
|
|
72
|
+
// token 数的情况下失败/被取消)。金额按 null 记,但 todayCost 会把条数汇总成可见信号,
|
|
73
|
+
// 免得"没记账"被读成"没花钱"。
|
|
74
|
+
usageUnknown: entry.usageUnknown === true ? true : undefined,
|
|
75
|
+
note: entry.note ? String(entry.note).slice(0, 200) : undefined,
|
|
71
76
|
});
|
|
72
77
|
// v0.6.3(BUG-009):**追加与轮转共用同一把锁**。
|
|
73
78
|
// 原实现是「锁外追加 + 锁内轮转」,仍留有丢失窗口:A 追加 → B 追加 → A 进锁读整文件、
|
|
@@ -146,6 +151,22 @@ export function recordCacheStats(/** @type {any} */ entry) {
|
|
|
146
151
|
let /** @type {any} */ _statsCache = null;
|
|
147
152
|
|
|
148
153
|
export function listCacheStats(limit = 2000) {
|
|
154
|
+
const r = listCacheStatsStrict(limit);
|
|
155
|
+
return r.ok ? r.entries : [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 严格读取(v0.6.7 / 报告一 H-3):区分「文件不存在」与「真的读不出来」。
|
|
160
|
+
*
|
|
161
|
+
* 为什么必须区分:`todayCost()` 护栏此前经过**宽松版**读取——`listCacheStats` 内部 `catch → []`,
|
|
162
|
+
* 于是"statSync 能过、readFileSync 失败"(Windows 杀软锁文件、权限竞态、盘符瞬断)会被当成
|
|
163
|
+
* **空列表 = 今日消费 ¥0**,护栏据此判定"没超支"并继续放行,且**没有任何告警**——
|
|
164
|
+
* 与该文件自己写下的「绝不静默当 0」(cost-guard.js)的纪律直接冲突。
|
|
165
|
+
* 护栏要的是「能不能读到」,不是「读到几条」。
|
|
166
|
+
* @param {number} [limit]
|
|
167
|
+
* @returns {{ok: true, entries: any[]} | {ok: false, error: string, code: string}}
|
|
168
|
+
*/
|
|
169
|
+
export function listCacheStatsStrict(limit = 2000) {
|
|
149
170
|
try {
|
|
150
171
|
const file = cacheStatsFile();
|
|
151
172
|
const st = fs.statSync(file);
|
|
@@ -160,9 +181,10 @@ export function listCacheStats(limit = 2000) {
|
|
|
160
181
|
}
|
|
161
182
|
_statsCache = { mtimeMs: st.mtimeMs, size: st.size, limit, lines: out };
|
|
162
183
|
}
|
|
163
|
-
return _statsCache.lines.slice(-limit);
|
|
164
|
-
} catch {
|
|
165
|
-
|
|
184
|
+
return { ok: true, entries: _statsCache.lines.slice(-limit) };
|
|
185
|
+
} catch (/** @type {any} */ err) {
|
|
186
|
+
// 读失败时**不能**复用旧缓存:这份数据是护栏的判据,宁可说"读不到"也不能给一份过期账
|
|
187
|
+
return { ok: false, error: String(err?.message ?? err), code: String(err?.code || '') };
|
|
166
188
|
}
|
|
167
189
|
}
|
|
168
190
|
|
|
@@ -198,9 +220,17 @@ export function recordUsage(/** @type {any} */ modelName, /** @type {any} */ usa
|
|
|
198
220
|
const priceAt = Number(perf?.requestStartAt) > 0 ? new Date(Number(perf.requestStartAt)) : undefined;
|
|
199
221
|
const prompt = usage?.prompt_tokens || 0;
|
|
200
222
|
const completion = usage?.completion_tokens || 0;
|
|
223
|
+
// v0.6.7(报告一 H-4 / 报告二 S-2「未知 ≠ 0」):服务端没回 usage 的调用**绝不能**以 ¥0 入账——
|
|
224
|
+
// 那会让"少计"看起来像"没花钱",护栏(warn/block/downgrade)随之失明且无信号。
|
|
225
|
+
// 这里记 cost:null + usageUnknown:true,todayCost 会把它计成"今日有 N 次用量未知",
|
|
226
|
+
// 由护栏给出可见告警(金额无法估算,但事实被记下来了)。
|
|
227
|
+
const usageUnknown = perf?.usageUnknown === true;
|
|
201
228
|
let cost = null;
|
|
202
229
|
let saved = null;
|
|
203
|
-
if (
|
|
230
|
+
if (usageUnknown) {
|
|
231
|
+
cost = null;
|
|
232
|
+
saved = null;
|
|
233
|
+
} else if (split) {
|
|
204
234
|
const base = estimateCost(modelName, prompt, completion, null, priceAt);
|
|
205
235
|
cost = estimateCost(modelName, prompt, completion, split, priceAt);
|
|
206
236
|
if (base != null && cost != null) saved = base - cost;
|
|
@@ -217,6 +247,8 @@ export function recordUsage(/** @type {any} */ modelName, /** @type {any} */ usa
|
|
|
217
247
|
miss: split?.miss ?? null,
|
|
218
248
|
cost,
|
|
219
249
|
saved,
|
|
250
|
+
// v0.6.7(H-4):显式标记"这次用量未知",供 todayCost 汇总出可见信号
|
|
251
|
+
...(usageUnknown ? { usageUnknown: true } : {}),
|
|
220
252
|
steps: perf?.steps ?? undefined,
|
|
221
253
|
llmMs: perf?.llmMs ?? undefined,
|
|
222
254
|
toolMs: perf?.toolMs ?? undefined,
|
package/src/commands/pack.js
CHANGED
|
@@ -172,7 +172,11 @@ export async function handlePack(cmd, args) {
|
|
|
172
172
|
process.exitCode = 1;
|
|
173
173
|
return true;
|
|
174
174
|
}
|
|
175
|
-
|
|
175
|
+
// v0.6.7(报告二 P3-5):此前写进 `cwd/packs/<name>` —— 该目录**不在任何发现路径**里
|
|
176
|
+
// (发现层级:内置 <repo>/packs → ~/.mingdao/packs → 项目 .mingdao/packs(信任门)→ config.packs),
|
|
177
|
+
// 于是照做的新用户"Pack 不生效且没有任何报错"。默认写到项目级 `.mingdao/packs/`,
|
|
178
|
+
// 并在下一步提示里给出 trust 命令(项目级必须显式信任才挂载)。
|
|
179
|
+
const dir = path.resolve(process.cwd(), '.mingdao', 'packs', name);
|
|
176
180
|
if (fs.existsSync(dir)) {
|
|
177
181
|
console.log(`[错误] 目录已存在:${dir}`);
|
|
178
182
|
process.exitCode = 1;
|
|
@@ -228,6 +232,7 @@ export function createPack(ctx) {
|
|
|
228
232
|
console.log(`✓ 已生成脚手架:${dir}`);
|
|
229
233
|
console.log('下一步:');
|
|
230
234
|
console.log(` 1. 编辑 ${path.join(dir, 'pack.json')}(描述/权限)与 pack.mjs(工具/约束)`);
|
|
235
|
+
console.log(` 2. mingdao pack trust ${path.dirname(path.dirname(dir))} # 项目级 Pack 必须显式信任才会挂载`);
|
|
231
236
|
console.log(` 2. mingdao pack verify ${dir}`);
|
|
232
237
|
return true;
|
|
233
238
|
}
|
package/src/commands/repl.js
CHANGED
|
@@ -425,7 +425,10 @@ export async function runRepl(ctx) {
|
|
|
425
425
|
io.print('用法:/memory add <内容>');
|
|
426
426
|
continue;
|
|
427
427
|
}
|
|
428
|
-
|
|
428
|
+
// v0.6.7(报告二 P3-3):此前是**裸 appendFileSync** —— ①没有日期戳(与 appendMemory 的
|
|
429
|
+
// `- [date] …` 格式不一致,后续 dedupe/检索的日期解析会漏掉这类条目);
|
|
430
|
+
// ②文件不存在时按默认 0644 新建,绕过记忆文件 0600 的私有权纪律。改走同一条写路径。
|
|
431
|
+
appendMemory([String(text)]); // 与 memoryFile() 同一路径(<home>/AGENTS.md,即上面的 memPath)
|
|
429
432
|
io.print(style(`✓ 已追加到用户记忆 ${memPath}(后续会话自动生效)`, C.green));
|
|
430
433
|
} else if (arg === 'extract') {
|
|
431
434
|
io.startSpinner('正在从当前对话提炼记忆…');
|