mingdao-harness 0.6.2 → 0.6.3
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 +95 -19
- package/docs/PROVIDERS.md +22 -0
- package/docs/RELEASE-CHECKLIST.md +13 -2
- package/package.json +1 -1
- package/src/agent.js +76 -6
- package/src/atomic-write.js +187 -71
- package/src/commands/workspace.js +3 -3
- package/src/providers/index.js +70 -2
- package/src/tasks.js +19 -7
- package/src/tools/bash.js +96 -18
- package/src/web/attachments.js +7 -1
- package/src/web/routes/domains/sessions.js +3 -3
- package/src/web/routes/domains/workspace.js +6 -6
- package/src/web/server.js +8 -4
- package/src/workspace.js +29 -16
|
@@ -520,6 +520,71 @@ Windows 对这种情况回报的正是 **ENOENT**,与"本来就没有检查点
|
|
|
520
520
|
> 补上「冷却过后**不做任何重置**也必须自动恢复」这条断言后,该突变才被拦下。
|
|
521
521
|
> **断言要落在"这条修复独有的行为"上**,否则测的是别的东西。
|
|
522
522
|
|
|
523
|
+
## 3.26 已修复(第二十七批:文件锁的**阻塞面**——`Atomics.wait` 冻结事件循环)
|
|
524
|
+
|
|
525
|
+
自评报告 P2-7 拆成两半:**正确性**(死区、误抢活锁)在 §3.10 已修;本轮修的是**阻塞面**。
|
|
526
|
+
|
|
527
|
+
| 项 | 位置 | 核实结论 | 处理 |
|
|
528
|
+
| --- | --- | --- | --- |
|
|
529
|
+
| P2-7(阻塞面) | `src/atomic-write.js#withFileLockSync` | **实测复现并量化**:同步锁用 `Atomics.wait` 睡眠,等待期间**整个事件循环停摆**。持锁方存活 2.6 秒时,一个 100ms 的定时器在锁返回前**根本没触发**。对常驻 WebUI 而言,一次文件锁争用就冻结所有并发会话/权限确认/SSE 流 | ① 新增**异步版 `withFileLock`**(等待时 `await` 让出事件循环,语义与同步版完全一致:同一套 `tryAcquire` / 陈旧回收 / 可重入 / 超时与文案,**抽成共用逻辑以免两条路径漂移**);② 把**正好在 WebUI 请求路径上**的 `workspace.js` 全部 7 处锁迁到异步版(11 个 src 调用点 + 测试调用点补 `await`);③ 同步版保留给纯同步调用链,理由见下 |
|
|
530
|
+
|
|
531
|
+
### 迁移时发现的**必须先修的隐患**:可重入判据是进程级 Set
|
|
532
|
+
原实现用一个**进程级** `Set` 判"我是否已持有该锁"(可重入)。同步临界区不会 yield,所以一直没出事;
|
|
533
|
+
但**异步临界区会 yield**——此时另一个任务拿同一把锁会被**误判成可重入**而并发进入临界区,
|
|
534
|
+
读-改-写互相覆盖(丢失更新)。已改用 `AsyncLocalStorage` 把可重入限定在**同一条调用链**内:
|
|
535
|
+
并发任务各持各的集合,嵌套调用复用同一份。这是"能安全异步化"的前提,单独写了断言钉住。
|
|
536
|
+
|
|
537
|
+
> 顺带记一个**比预期严重**的失败形态:把异步锁的等待改回 `Atomics.wait` 后,测试不是"变慢",
|
|
538
|
+
> 而是**直接死锁**——A 阻塞事件循环等 B 持有的锁,而 B 永远没机会运行,最后锁超时。
|
|
539
|
+
> "在 async 函数里做阻塞等待"不只是性能问题,是能卡死整个进程的错误。
|
|
540
|
+
|
|
541
|
+
### 为什么只迁这一组,而不是 24 个调用点
|
|
542
|
+
`tasks`(2 处)/ `schedule`(12 处)/ `cachestats`(1 处)/ `sync`(1 处)**仍用同步版**,原因如实登记:
|
|
543
|
+
- 它们要么在**纯同步的读-改-写链**里(`patchTask` 被 `tasks.js` 内部多处同步调用,异步化会传染十几处,回归风险大于收益);
|
|
544
|
+
- 要么在**调度守护进程内部**(`schedule`)——那里的阻塞只会推迟定时任务,不会冻结用户请求;
|
|
545
|
+
- `cachestats` 的锁只在**轮转时**(文件 >4MB)取,且追加本身不加锁。
|
|
546
|
+
|
|
547
|
+
**因此本轮的结论是"请求路径不再冻结",不是"阻塞面已彻底消除"。** 剩余 16 处仍会在
|
|
548
|
+
极端争用下阻塞其所在进程——已量化、已登记,未假装解决。
|
|
549
|
+
|
|
550
|
+
### 测试(含对照组,否则容易恒绿)
|
|
551
|
+
1. **异步版不阻塞**:真跨进程持有者持锁 1.2s,等待期间 100ms 定时器**必须**按时触发;
|
|
552
|
+
2. **对照组**:同一场景下同步版返回时定时器**必须还没触发过**(证明断言有能力区分,而不是"根本没人持锁");
|
|
553
|
+
3. **真实路径**:`addWorkspace` 在争用时 50ms 心跳至少跳 3 次(退回同步锁时实测 **0 次 / 923ms**);
|
|
554
|
+
4. **可重入语义**:三个并发 async 任务各自 read-modify-write,必须都落上(丢失更新会暴露旧判据);同链嵌套不自死锁;
|
|
555
|
+
5. **陈旧回收**:持有者已死 → 立刻回收(不等 `staleMs`)。
|
|
556
|
+
|
|
557
|
+
### 新增常驻守卫:async 函数的调用点**必须 await**
|
|
558
|
+
这类迁移最容易漏的就是调用点——**漏了不报错**,只会让 `.ok` / `.error` 恒为 `undefined`,
|
|
559
|
+
于是断言**恒真**。本次就是靠这个扫描器抓出 src 里漏掉的 **2 处**
|
|
560
|
+
(`web/routes/domains/sessions.js` 的会话改名/删除映射——我先前手工 grep 时只看了 `touchWorkspace`,
|
|
561
|
+
scanner 是穷举的,手工 grep 不是)。守卫同时扫 `src/` 与 `test/`,并为两种正当写法放行:
|
|
562
|
+
作为回调传给已 await 的异步助手、以及在 `Promise.all` / `.then()` 内收口。
|
|
563
|
+
|
|
564
|
+
## 3.27 已修复(第二十八批:临界区里不该做的事 + 锁超时上限)
|
|
565
|
+
|
|
566
|
+
| 项 | 位置 | 核实结论 | 处理 |
|
|
567
|
+
| --- | --- | --- | --- |
|
|
568
|
+
| P2-7(临界区构成) | `src/tasks.js#killTask` | **发现并核实**:`killTask` 把 `killTaskInner` **整个**包在锁里,而它内部会调 `pidOwnedBy()`(非 Linux 回退到**同步** `execFileSync('ps')`)与 `killTree()`(Windows 上走**同步** `spawnSync('taskkill')`)——等于把外部进程调用塞进临界区,持锁时间从毫秒级变成百毫秒级,**每个等锁的人都被拖住**。实测纯状态的临界区极短(小文件读-改-写 **0.13ms**、最重的 cache-stats 轮转 **6ms**),也就是说长持锁完全是"自己把慢操作放进去"造成的 | 把进程操作**全部移到锁外**,临界区只剩状态读-改-写。**语义不变**:状态写回仍在锁内(与 worker 的终态写互斥),P3 T20 的「killed 优先」保护仍在 `patchTask` 里 |
|
|
569
|
+
| P2-7(超时上限) | `src/atomic-write.js` | 默认 `timeoutMs=20000` 意味着「持有者活着但卡死」时,等锁方会被冻 **20 秒** | 依实测收紧为 **5000 / 4000**(仍满足 `timeout > stale`,不留死区):实测最坏临界区 6ms,5 秒是它的约 **800 倍**,正常争用绝不会误判超时,而最坏冻结直接降到四分之一。两个值都可用 `MINGDAO_LOCK_TIMEOUT_MS` / `MINGDAO_LOCK_STALE_MS` 覆盖;超时错误改为**可操作**(说清锁文件在哪、怎么看持有者 pid、什么时候可以删) |
|
|
570
|
+
|
|
571
|
+
### 更正 §3.26 里我自己的一个说法
|
|
572
|
+
§3.26 结尾我写「`tasks` 是剩余项里唯一还在用户请求路径上的」——**这句话不准确**,本轮核实后更正:
|
|
573
|
+
WebUI 的 `web/` 层**只 import `listTasks`**(读路径、不加锁),**从不直接调用** `patchTask` / `killTask`。
|
|
574
|
+
`tasks` 的锁只会在一个分支上被请求路径碰到:`listTasks` 默认 `reap=true` → 回收器发现**死掉的任务**时才
|
|
575
|
+
调 `patchTask`。也就是说这是"罕见分支 + 毫秒级临界区",比我上次说的轻。
|
|
576
|
+
**记录这个更正本身**:宁可当场纠正自己的判断,也不要让一个听起来更严重的说法留在文档里。
|
|
577
|
+
|
|
578
|
+
### 把残留变成清单(与"静默吞写白名单"同款做法)
|
|
579
|
+
剩余 **15 处**同步锁调用点已列成受审阅清单(`cachestats` 1 / `schedule` 12 / `sync` 1 / `tasks` 1),
|
|
580
|
+
每处写明为什么可以留;测试断言「清单与代码逐项一致」——**新增一处同步锁即失败**,
|
|
581
|
+
并提示"WebUI 请求路径请改用异步版"。
|
|
582
|
+
|
|
583
|
+
### 顺带修掉一处**会无声碎掉的源码断言**
|
|
584
|
+
既有测试 92d 用正则 `timeoutMs = (\d+), staleMs = (\d+) }` 去 **grep 源码文本**。
|
|
585
|
+
本轮把默认值换成命名常量后,这条断言直接匹配不到(`m92` 为 null)——**源码文本一改就碎**。
|
|
586
|
+
已改为读导出的 `lockDefaults()`:断言的是**行为契约**(`timeout > stale` 且上限有界),不是字符串长相。
|
|
587
|
+
|
|
523
588
|
## 4. 其余登记项(**第三方结论,我未逐条复核**)
|
|
524
589
|
|
|
525
590
|
### 4.1 自评报告(`MingDao-harness-v0.6.1-技术评估报告.md`)
|
|
@@ -535,11 +600,11 @@ Windows 对这种情况回报的正是 **ENOENT**,与"本来就没有检查点
|
|
|
535
600
|
| ~~P2-4~~ | ~~Web 会话忙锁~~ | ✅ **已修**(见 §3.11) |
|
|
536
601
|
| ~~P2-5~~ | ~~终端渲染~~ | ✅ **已修**(见 §3.1,`io.print` 统一过 `sanitizeKeepingSgr`) |
|
|
537
602
|
| ~~P2-6~~ | ~~`sleeperAlive`~~ | ✅ **已修**(见 §3.8) |
|
|
538
|
-
| P2-7 |
|
|
603
|
+
| ~~P2-7~~ | ~~文件锁~~ | ✅ **已修**(陈旧判据见 §3.10;**阻塞面**见 §3.26 异步锁 + §3.27 临界区收窄/超时上限。残留 15 处同步锁已列受审阅清单) |
|
|
539
604
|
| ~~P2-8~~ | ~~出网闸门~~ | ✅ **已修**(见 §3.14) |
|
|
540
605
|
| ~~P2-9~~ | ~~项目记忆~~ | ✅ **已修**(见 §3.2) |
|
|
541
|
-
| P2-10 |
|
|
542
|
-
| P2-11 |
|
|
606
|
+
| ~~P2-10~~ | ~~`killTask`~~ | ✅ **已修**(见 §3.9:升级 SIGKILL + 立即置终态) |
|
|
607
|
+
| ~~P2-11~~ | ~~调度 `runOnce`~~ | ✅ **已修**(见 §3.9:轮询期间检查租约) |
|
|
543
608
|
| P3-1 | 避峰备注 | 写「北京时间」却打印 UTC(错 8 小时) |
|
|
544
609
|
| P3-2 | `kind:'every'` | `nextRunAt` 缺失时每 1 秒空转 |
|
|
545
610
|
| P3-3 | git 工具 | 默认加 `--stat` 与模型显式 `--no-stat` 冲突 |
|
|
@@ -575,20 +640,20 @@ Windows 对这种情况回报的正是 **ENOENT**,与"本来就没有检查点
|
|
|
575
640
|
| --- | --- | --- | --- |
|
|
576
641
|
| B-CMD-3 | 凭据泄露 | `sync passwd` 新密码走命令行 | ✅ 已修(§3.1) |
|
|
577
642
|
| ~~B-CMD-1/2~~ | 资源泄漏 | readline 未 close | ✅ **已核实为非缺陷**(§3.20 附:全仓仅 3 处 createInterface,均已有 close;EOF 下 question 正常回调) |
|
|
578
|
-
| B-ATW-1/2 | 环境兼容 | SharedArrayBuffer / `Atomics.wait` 阻塞 |
|
|
643
|
+
| B-ATW-1/2 | 环境兼容 | SharedArrayBuffer / `Atomics.wait` 阻塞 | ✅ **已处理**(§3.10 正确性 + §3.26 异步锁 + §3.27 临界区收窄与超时上限;残留 15 处已列清单,理由见 §3.27) |
|
|
579
644
|
| B-CT-1 | 性能 | 「WeakMap 缓存永远 miss,每步全量 BPE」 | ✅ **已核实并按其真实影响处理**(§3.15):**"永远 miss"不成立**——同一计数器下 200 次调用 0.0ms;真问题是计数器 identity 不稳定,已按模型名缓存 |
|
|
580
645
|
| ~~B-CON-1~~ | 安全/注入 | ReDoS | ✅ **已核实并修复**(§3.16:约束 pattern 的嵌套量词,实测 29 字符 4.8 秒) |
|
|
581
646
|
| ~~B-HK-1~~ | 安全/注入 | `shell:true`(`hooks.js`) | ✅ **已核实为非缺陷**(§3.16:hooks 来自用户自己的 config,Pack 无法贡献) |
|
|
582
|
-
| B-SR-1 | 安全/注入 | 路径穿越 |
|
|
647
|
+
| ~~B-SR-1~~ | 安全/注入 | 路径穿越 | ✅ **已核实并修复,且实测复现**(§3.24:registry 索引名 `.`/`..` 拼进安装路径 → `{"name":".."}` 清空整个 MINGDAO_HOME。**注意 §3.23 一度误判为「未发现」,已更正**) |
|
|
583
648
|
| A-PI-1 | 安全 | 「无 Key 强制」 | ✅ v0.6.0 C4 已处理(本地/内网端点免 Key) |
|
|
584
649
|
| ~~A-LG-1~~ | 数据完整性 | 哈希链盲区 | ✅ **已核实并修复**(§3.17:盲区是**尾部截断**,实测「只留前 3 行」旧实现报 ok:true。已加封条侧车) |
|
|
585
|
-
| B-CS-1 | 数据完整性 | 竞态丢数据 | ⚠
|
|
586
|
-
| B-WS-1/2 | 数据完整性 | 静默吞写入 |
|
|
587
|
-
| B-TOK-1 | 数据完整性 | 词表永不重试 |
|
|
588
|
-
| B-SL-1 | 资源泄漏 | 临时目录 |
|
|
650
|
+
| B-CS-1 | 数据完整性 | 竞态丢数据 | ⚠ **仍未逐条核实**:已复核「写路径都在跨进程锁内」(workspace/tasks/schedule/cachestats/sync),并为异步锁加了并发读-改-写断言(3 个并发任务必须都落上);但这不等于「全仓无丢更新」,报告未给位置 |
|
|
651
|
+
| ~~B-WS-1/2~~ | 数据完整性 | 静默吞写入 | ✅ **已穷举并清完**(§3.17–§3.21:账本/任务检查点/费用明细/会话索引/工作空间/守护 pidfile/记忆去重/审计日志 共 8 处)。剩余静默吞写已列**受审阅白名单**并由测试常驻约束 |
|
|
652
|
+
| ~~B-TOK-1~~ | 数据完整性 | 词表永不重试 | ✅ **已核实并修复**(§3.25:一次读失败终生锁死 + 无声降级;改为有界重试 + 降级可见) |
|
|
653
|
+
| B-SL-1 | 资源泄漏 | 临时目录 | 🟡 **已普查**(全仓 3 处 `mkdtempSync`:`skill-lib.js` ×2 均有 `rmSync`、`skill-registry.js` 有 `finally` 清理)——**未发现泄漏**,但报告未给位置,只能给到普查结论 |
|
|
589
654
|
| ~~B-UI-1 / B-CLI-1 / B-REPL-1~~ | EPIPE | 标准输出 EPIPE 未处理 | ✅ **已核实并修复**(§3.20:实测裸 write 会带堆栈崩溃,已加两种策略的兜底) |
|
|
590
655
|
| B-CMD-4 | 凭据泄露 | skill token | ⚠ 待核实 |
|
|
591
|
-
| D-REL-1/2 | 凭据泄露 | gitee token(发布链路) |
|
|
656
|
+
| ~~D-REL-1/2~~ | 凭据泄露 | gitee token(发布链路) | ✅ **已核实并修复**(§3.22:实测确认 argv / 回显 / URL 三条泄露路径,改走 ssh stdin + HTTP 头鉴权 + 读后即删) |
|
|
592
657
|
|
|
593
658
|
> **要真正排期这 22 项,需要那份 `defect-register.md`**(含 file:line);
|
|
594
659
|
> 上表的"待核实"不是"已确认存在",而是**该报告未给出可复现位置**、我尚未逐条定位。
|
|
@@ -597,23 +662,30 @@ Windows 对这种情况回报的正是 **ENOENT**,与"本来就没有检查点
|
|
|
597
662
|
|
|
598
663
|
## 5. 处理顺序与进度
|
|
599
664
|
|
|
600
|
-
**总口径**:三份第三方报告的可执行条目,**已核实并处理
|
|
665
|
+
**总口径**:三份第三方报告的可执行条目,**已核实并处理 28 批**(§2.1–§3.27)。
|
|
601
666
|
每一批都要求:先复现 → 再定级 → 修复 → 回归断言 → **变异验证断言本身**。
|
|
602
|
-
「先复现」对两个方向同样适用:B-CT-1 标题夸大(实测不复现),B-CON-1 / A-LG-1 实测确实严重。
|
|
667
|
+
「先复现」对两个方向同样适用:B-CT-1 标题夸大(实测不复现),B-CON-1 / A-LG-1 / B-SR-1 实测确实严重。
|
|
603
668
|
|
|
604
669
|
**已闭合的类别**:
|
|
605
670
|
|
|
606
671
|
- 三个过程缺陷(§2.1)、缓存跨代理污染(§2.2)、**项目级 Pack 信任门**(§3)
|
|
607
|
-
- **凭据泄露**:`sync passwd` 命令行传密(§3.1 P2-6)、诊断包结构脱敏(§3.3 P2-13
|
|
672
|
+
- **凭据泄露**:`sync passwd` 命令行传密(§3.1 P2-6)、诊断包结构脱敏(§3.3 P2-13)、
|
|
673
|
+
**发布链路 token 进 argv/URL/回显**(§3.22 D-REL-1/2,实测确认三条路径)
|
|
608
674
|
- **静默失效**:出网闸门丢 Request 语义(§3.1 P2-8)、旧模型名静默失效(§3.4)、
|
|
609
|
-
自启文件未转义(§3.5)、MCP 预设参数(§3.6)、**账本写失败无声 + 尾部截断不可检出**(§3.17
|
|
675
|
+
自启文件未转义(§3.5)、MCP 预设参数(§3.6)、**账本写失败无声 + 尾部截断不可检出**(§3.17)、
|
|
676
|
+
**任务检查点/费用明细/会话索引/工作空间/守护 pidfile/记忆去重/审计日志的"写失败却报成功"**(§3.18–§3.21)、
|
|
677
|
+
**词表读失败终生锁死**(§3.25)
|
|
610
678
|
- **终端注入**:控制序列过滤(§3.1 P2-5)
|
|
611
679
|
- **记忆注入**:项目记忆持久化围栏(§3.2 P2-9)
|
|
612
680
|
- **日志/调度/锁**:写入与轮转按大小触发(§3.7)、进程归属校验与整组清理(§3.8)、
|
|
613
|
-
`/proc` 命令行归一(§3.8 附)、锁陈旧判据看持有者 pid(§3.10
|
|
681
|
+
`/proc` 命令行归一(§3.8 附)、锁陈旧判据看持有者 pid(§3.10)、
|
|
682
|
+
**锁的阻塞面:异步锁 + 请求路径迁移 + 临界区收窄 + 超时上限**(§3.26–§3.27)
|
|
614
683
|
- **能力声明不一致**:deny 按段匹配 / git 缩写前缀 / permissions 说实话 / 忙锁键迁移(§3.11)
|
|
615
684
|
- **安全**:SSRF 逐跳复检单一来源(§3.12)、token 计数器 identity(§3.13)、
|
|
616
|
-
约束 pattern 防灾难性回溯(§3.16)、`hooks.js` shell:true 核实为**非缺陷**(§3.16
|
|
685
|
+
约束 pattern 防灾难性回溯(§3.16)、`hooks.js` shell:true 核实为**非缺陷**(§3.16)、
|
|
686
|
+
**路径穿越实测复现并修复**(§3.24 B-SR-1)、**stdout EPIPE 崩溃**(§3.20)
|
|
687
|
+
- **发布纪律固化**:四平台(GitHub + Gitee + GitCode + npm)一致性由
|
|
688
|
+
`scripts/verify-release.mjs` 在发版最后一步自动校验;v0.6.2 已按该流程发布(四渠道校验退出 0)
|
|
617
689
|
|
|
618
690
|
**未闭合(如实登记,不假装完成)**:
|
|
619
691
|
|
|
@@ -622,9 +694,13 @@ Windows 对这种情况回报的正是 **ENOENT**,与"本来就没有检查点
|
|
|
622
694
|
`memory`(去重) / `audit` **已修**;`titles` 复核为**非缺陷**(调用点已判空)、
|
|
623
695
|
`compact` 复核为**正常重试写法**。剩余 18 处已在 §3.21 的白名单里**逐条写明可以吞的理由**,
|
|
624
696
|
并由测试常驻约束——新增即失败。**这一类不再靠人工记忆。**
|
|
625
|
-
2.
|
|
626
|
-
|
|
627
|
-
|
|
697
|
+
2. ~~**`withFileLockSync` 的阻塞面**(自评 P2-7)~~ 🟡 **已处理到"可接受"**:
|
|
698
|
+
§3.26 新增异步版并把 **WebUI 请求路径**上的 `workspace`(7 处)迁过去;
|
|
699
|
+
§3.27 把 `tasks` 的进程操作移出临界区、把同步锁超时上限 20s → 5s(依实测),
|
|
700
|
+
并给剩余 **15 处**同步锁列出受审阅清单(含理由,新增即测试失败)。
|
|
701
|
+
**仍有 15 处在极端争用下会阻塞其所在进程**——但已核实:它们要么不在请求路径上
|
|
702
|
+
(`schedule` 在守护进程内、`sync` 是 CLI 一次性命令、`cachestats` 仅在 >4MB 轮转时),
|
|
703
|
+
要么临界区已缩到毫秒级。**不宣称"阻塞面已彻底消除"**,只说"已知残留均为可接受并受清单约束"。
|
|
628
704
|
3. ~~**EPIPE 未处理**(B-UI-1 / B-CLI-1 / B-REPL-1)~~ ✅ **已修**(§3.20:实测复现并加了两种策略的兜底)。
|
|
629
705
|
4. ~~**readline 未 close**(B-CMD-1/2)~~ ✅ **已普查完毕**:全仓仅 3 处 `createInterface`
|
|
630
706
|
(`ui.js` / `commands/sync.js` / `commands/skill.js`),**都已有 `close()`**;
|
package/docs/PROVIDERS.md
CHANGED
|
@@ -80,6 +80,28 @@ export async function createProvider(cfg) {
|
|
|
80
80
|
|
|
81
81
|
规则:`provider` 名不在内置预设中且存在同名模块文件时,优先加载该模块;否则按 OpenAI 兼容方式使用 `baseUrl`。
|
|
82
82
|
|
|
83
|
+
### 声明能力(可选,v0.6.3 起)
|
|
84
|
+
|
|
85
|
+
模块可以**静态声明**能力,内核据此决定界面行为(目前用于图片输入门控):
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
// ~/.mingdao/providers/dify.mjs
|
|
89
|
+
export const supportsVision = true; // 或 export const capabilities = { vision: true }
|
|
90
|
+
export async function createProvider(cfg) { /* … */ }
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
- 只读**静态导出**,不调用 `createProvider()`(后者可能有副作用:起进程、建连接);按文件 mtime 缓存。
|
|
94
|
+
- 声明缺失时该 Provider 视为不支持图片——**门控保守**,不会把图发给看不懂的端点。
|
|
95
|
+
- 为什么需要它:**为了打开图片门控而往 `config.customModels` 里加条目,会改变"请求发给谁"**。
|
|
96
|
+
自 v0.6.3 起 `customModels` 的条目分两类,只有写了传输字段的才算自定义端点:
|
|
97
|
+
|
|
98
|
+
| 条目内容 | 含义 | 效果 |
|
|
99
|
+
| --- | --- | --- |
|
|
100
|
+
| 含 `baseUrl` / `apiKey` / `envKey` / `headers` / `path` / `kind` | **声明式端点** | 走 `custom:<模型名>` 的 OpenAI 兼容直连(优先于内置预设,原行为) |
|
|
101
|
+
| 只含 `vision` / `tokenizer` / `contextWindow` / `maxOutputTokens` / `local` / `provider` | **纯能力覆盖** | **不改变 transport**;`provider` 字段只作路由提示(可指向你的自定义模块) |
|
|
102
|
+
|
|
103
|
+
也就是说:「打开一个能力开关」不再改变「请求发给谁」。
|
|
104
|
+
|
|
83
105
|
## 模型预设(src/models.js)
|
|
84
106
|
|
|
85
107
|
每个模型预设定义:`provider` / `contextWindow` / `budgetTokens`(默认注入预算)/ `maxOutputTokens` / `temperature` / `supportsReasoning`。自定义模型名没有预设时使用安全默认值(预算 128k、输出 8k、温度 0.6),都可以在 `config.json` 用 `contextBudget` / `maxOutputTokens` / `temperature` 覆盖。
|
|
@@ -266,8 +266,13 @@ git push <mirror> refs/tags/v<版本>:refs/tags/v<版本>
|
|
|
266
266
|
|
|
267
267
|
```bash
|
|
268
268
|
# 采集:用脚本,别再手写 /tmp/harvest-<版本>.sh(手写漏过 mac zip,见 ①)
|
|
269
|
-
|
|
270
|
-
|
|
269
|
+
scp MingDao-Harness-Site/scripts/harvest-release.mjs mingdao-server:/tmp/
|
|
270
|
+
# ⚠ token **不要**写进 ssh 的命令串(那等于放进 argv,本地与服务器的 ps 都能读到几小时,
|
|
271
|
+
# 与 §3.22 修掉的是同一个毛病)。经 stdin 落到 umask 077 的临时文件,用完即删:
|
|
272
|
+
umask 077; printf 'MINGDAO_GITHUB_TOKEN=%s\n' "$MINGDAO_GITHUB_TOKEN" > /tmp/hr.env
|
|
273
|
+
ssh mingdao-server 'umask 077; cat > /tmp/harvest.env' < /tmp/hr.env && rm -f /tmp/hr.env
|
|
274
|
+
ssh mingdao-server 'set -a; . /tmp/harvest.env; set +a; rm -f /tmp/harvest.env; \
|
|
275
|
+
node /tmp/harvest-release.mjs <版本> /opt/1panel/www/sites/mingdao-site/downloads'
|
|
271
276
|
# · 采安装包 + *.blockmap + latest.yml/latest-linux.yml,跳过 latest-mac.yml 与 builder-debug.yml
|
|
272
277
|
# · 每个文件按 GitHub 的 sha256(digest) + size 双校验,不符即删并退出非 0
|
|
273
278
|
# · 末尾会打印「差量更新素材」自查(缺哪个平台的 .blockmap 会直接点名)
|
|
@@ -379,6 +384,12 @@ node scripts/verify-release.mjs <版本> # 四个渠道逐项核对,任一缺
|
|
|
379
384
|
- [ ] `node scripts/verify-release.mjs <版本>` **退出 0**
|
|
380
385
|
- [ ] 若输出 `⚠ npm/dist-tags.latest` 不是本版本,确认这是有意的回填(否则 latest 需要修正)
|
|
381
386
|
|
|
387
|
+
> **v0.6.2 发布实测**:该脚本第一次跑报 `✗ GitHub/main 取不到`,第二次即通过——
|
|
388
|
+
> 是一次 `git ls-remote` 的瞬时失败。**一次网络抖动不该在发版收尾时喊狼来了**:
|
|
389
|
+
> 误报会训练人忽略这条告警,而它恰恰是防半发布的最后一道闸。现已加**三次有界重试**,
|
|
390
|
+
> 并把「取不到」(网络/限流)与「读到了但不一样」(真的没推)在输出里区分开。
|
|
391
|
+
> 若仍显示「取不到」,先确认网络与额度再重跑,**不要**跳过这一步。
|
|
392
|
+
|
|
382
393
|
### 3.3 Gitee / GitCode 同步发行(含附件)
|
|
383
394
|
|
|
384
395
|
```bash
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -33,6 +33,57 @@ const SUBAGENT_MAX_STEPS = 24;
|
|
|
33
33
|
// 导致基准测的不是真实只读档。导出后基准与实现共用同一集合。
|
|
34
34
|
export const READONLY_TIER_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'todo', 'git', 'fetch', 'task']);
|
|
35
35
|
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// 写意图 / 疑问句 / 未达成意图的判定(v0.6.3)
|
|
38
|
+
//
|
|
39
|
+
// 这几个判定决定「本回合暴露哪些工具」,因此**放到模块级并导出**:判定错一次,
|
|
40
|
+
// 用户看到的就是"模型说做不到"而完全不知道原因(下游 Deyi-TCM 随访的「回访」正是如此)。
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
const WRITE_INTENT_RE = /写|建|创|改|修|删|装|加|添|增|补|换|移|部署|执行|运行|实现|重构|生成|迁移|安装|更新|升级|发布|调整|优化|修复|提交|推送|打包|编译|测试|implement|fix|create|modify|update|delete|deploy|build|make|generate|install|write|refactor|migrate|test|run|commit|push|remove|add|change|patch/i;
|
|
43
|
+
const hasWriteIntent = (/** @type {any} */ text) => WRITE_INTENT_RE.test(String(text || ''));
|
|
44
|
+
// v0.6.3(下游 Deyi-TCM 随访实测的上游缺口):只读档的**判定方向必须反过来**。
|
|
45
|
+
//
|
|
46
|
+
// 原逻辑:命中写意图关键词 → 给全量工具;否则整回合只读(write/edit/bash 对模型**不可见**)。
|
|
47
|
+
// 但域内任务用的是**域内动词/名词**——「回访」「排班」「盘点」「对账」「随访」「归档」……
|
|
48
|
+
// 这是**开集**,永远枚举不完。下游实测:「请生成回访看板」命中"生成"→✅ 能用;
|
|
49
|
+
// 而「回访」不命中任何关键词 → 只读档 → 工具不可见 → ❌ 高频入口直接废掉。
|
|
50
|
+
//
|
|
51
|
+
// 现在改为:**只有"看起来是纯提问、且不含写意图"才进只读档**,其余一律给全量工具。
|
|
52
|
+
// 省 token 的初衷不变(真正的问答仍然是只读档),但默认从"任务态"出发——
|
|
53
|
+
// 少省一点 token 远好过"任务做不了且用户看不出原因"。
|
|
54
|
+
const QUESTION_RE = /[??]|什么|怎么|怎样|如何|为何|为什么|是否|能否|可否|多少|几个|哪些|哪个|哪里|哪个|谁|何时|吗$|吗[。!!]|呢[。!!]?$/i;
|
|
55
|
+
const QUESTION_RE_EN = /\b(what|how|why|is|are|does|do|can|could|should|when|where|which|who)\b/i;
|
|
56
|
+
const looksLikeQuestion = (/** @type {any} */ text) => {
|
|
57
|
+
const t = String(text || '').trim();
|
|
58
|
+
if (!t) return false;
|
|
59
|
+
return QUESTION_RE.test(t) || QUESTION_RE_EN.test(t);
|
|
60
|
+
};
|
|
61
|
+
// 自愈信号:只读档下模型"想做但做不了 / 要用户来做"时的典型措辞(中英)。
|
|
62
|
+
const UNMET_INTENT_RE = /无法|不能|没法|做不到|没有权限|未提供|需要你|需要先|请先|建议你|请确认|请提供|如需|无法直接|不能直接|请告知|需要我|我可以帮你|unable to|can'?t|cannot|need you|need to know|please confirm|please provide/i;
|
|
63
|
+
const looksLikeUnmetIntent = (/** @type {any} */ text) => UNMET_INTENT_RE.test(String(text || ''));
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 本回合是否从「只读档」起步。
|
|
67
|
+
*
|
|
68
|
+
* **判定方向是故意反的**:原实现要命中写意图关键词才给全量工具,而域内任务用的是
|
|
69
|
+
* **开集**动词/名词——「回访」「排班」「盘点」「对账」……永远枚举不完。下游实测:
|
|
70
|
+
* 「请生成回访看板」命中"生成"→能用;「回访」不命中→只读档→write/edit/bash 不可见→废掉。
|
|
71
|
+
* 现在只有"看起来是纯提问且不含写意图"才进只读档(真正的问答照样省 token),其余一律全量。
|
|
72
|
+
*
|
|
73
|
+
* 另外两条防线(都写在这里,保证判定只有一处):
|
|
74
|
+
* · **域内 Pack 在场时不进只读档**:这类部署的价值在"任务能做",不在省 schema token;
|
|
75
|
+
* · 只读档若以纯文本收尾且透出"做不到/需要…",`runTurn` 会**自愈**放开一次(与词表无关)。
|
|
76
|
+
*
|
|
77
|
+
* @param {any} cfg @param {any} lastUserText @param {boolean} packActive
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
export function startsInReadOnlyPhase(cfg, lastUserText, packActive) {
|
|
81
|
+
if (cfg?.schemaTier === false) return false;
|
|
82
|
+
if (packActive) return false;
|
|
83
|
+
const t = lastUserText;
|
|
84
|
+
return !hasWriteIntent(t) && looksLikeQuestion(t);
|
|
85
|
+
}
|
|
86
|
+
|
|
36
87
|
/**
|
|
37
88
|
* 创建 Agent 循环(调用方只需传 provider/permission/io/modelName/workingDir,其余可选)
|
|
38
89
|
* @param {{ provider: any, permission: any, io: any, modelName: any, workingDir: any,
|
|
@@ -121,8 +172,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
121
172
|
// (readOnly 子代理只读,权限引擎仍门控写操作,无越权)。
|
|
122
173
|
// 只读档工具集:单一来源见模块顶部导出的 READONLY_TIER_SET
|
|
123
174
|
// 中英双语写意图(CodeArts 报告:纯中文正则让英文会话整回合只读死锁)
|
|
124
|
-
|
|
125
|
-
const hasWriteIntent = (/** @type {any} */ text) => WRITE_INTENT_RE.test(String(text || ''));
|
|
175
|
+
|
|
126
176
|
// A1(前缀稳定):剥描述集合按「回合冻结快照」——回合内恒定(至多两态:只读档/全量档),
|
|
127
177
|
// 新使用的工具只在下一回合才进入剥描述集合;回合边界本身就有新 user 消息,schema 变化免费。
|
|
128
178
|
// v0.4.0 Agent Preset:cfg.presetTools 白名单恒生效(在只读档过滤之后收紧——预设只减不增)。
|
|
@@ -401,11 +451,14 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
401
451
|
// 省钱 B1:本回合只读阶段判定——最新用户消息无写意图则先只发只读工具,
|
|
402
452
|
// 模型明确表达写意图后(下一轮)注入全量。cfg.schemaTier=false 可关。
|
|
403
453
|
let readOnlyPhase = true;
|
|
404
|
-
|
|
454
|
+
// 自愈只允许一次(否则与 stepLimit 形成"放开→再收起"的循环)
|
|
455
|
+
let readOnlyHealed = false;
|
|
456
|
+
{
|
|
405
457
|
const lastUser = [...messages].reverse().find((m) => m?.role === 'user');
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
458
|
+
// 域内 Pack 在场时**不进只读档**(域内词是开集,靠关键词判定必然漏)
|
|
459
|
+
const packCtx0 = getActivePackContext();
|
|
460
|
+
const packActive = Array.isArray(packCtx0?.mounted) && packCtx0.mounted.length > 0;
|
|
461
|
+
readOnlyPhase = startsInReadOnlyPhase(cfg, String(lastUser?.content ?? ''), packActive);
|
|
409
462
|
}
|
|
410
463
|
// A1:本回合的剥描述冻结快照(会话级 usedToolNames 的副本)——回合内 schema 字节不变
|
|
411
464
|
const turnStrippedSet = new Set(usedToolNames);
|
|
@@ -1100,6 +1153,23 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1100
1153
|
});
|
|
1101
1154
|
continue;
|
|
1102
1155
|
}
|
|
1156
|
+
// v0.6.3 自愈:只读档下 write/edit/bash 不可见,域内任务只会收到一段
|
|
1157
|
+
// "我做不到/需要你提供…"的文字,而**根本不会进第二轮**——原释放条件
|
|
1158
|
+
// (模型文字里出现写意图)因此永远等不到。这里补一条与词表无关的兜底。
|
|
1159
|
+
if (readOnlyPhase && !readOnlyHealed && looksLikeUnmetIntent(finalText)) {
|
|
1160
|
+
readOnlyHealed = true;
|
|
1161
|
+
readOnlyPhase = false;
|
|
1162
|
+
try {
|
|
1163
|
+
io.print(style('⚠ 上一轮处于只读档、未暴露写入类工具;已放开工具集并重试一次', C.dim));
|
|
1164
|
+
} catch {}
|
|
1165
|
+
messages.push({
|
|
1166
|
+
role: 'user',
|
|
1167
|
+
content:
|
|
1168
|
+
'(系统提示)你上一轮可能因为没有可用的写入/执行工具而未能完成任务。' +
|
|
1169
|
+
'现在工具集已放开:如果用户的要求需要写文件或执行命令,**请直接调用相应工具去做**,不要只描述计划或让用户自己完成。',
|
|
1170
|
+
});
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1103
1173
|
return {
|
|
1104
1174
|
text: finalText,
|
|
1105
1175
|
reasoning: res.reasoning || '',
|
package/src/atomic-write.js
CHANGED
|
@@ -4,12 +4,25 @@
|
|
|
4
4
|
// 存在丢更新与"rename 到对方半截文件"的成体系隐患;config/credentials 等关键文件更是直写。
|
|
5
5
|
// 本模块提供两个原语:
|
|
6
6
|
// 1. atomicWriteFileSync —— tmp 名含 pid+随机后缀(跨进程绝不共名),写完 rename 原子替换;
|
|
7
|
-
// 2. withFileLockSync —— O_EXCL lockfile
|
|
8
|
-
//
|
|
7
|
+
// 2. withFileLockSync —— O_EXCL lockfile 互斥(带超时与陈旧锁回收),包裹读-改-写序列;
|
|
8
|
+
// 3. withFileLock —— **同一套语义的异步版**:等待期间 `await` 让出事件循环,不阻塞 WebUI。
|
|
9
|
+
//
|
|
10
|
+
// v0.6.2(自评 P2-7 的阻塞面):同步版用 `Atomics.wait` 睡眠,**等待期间整个事件循环停摆**
|
|
11
|
+
// ——实测持锁方存活 2.6 秒时,一个 100ms 的定时器在锁返回前根本没触发。对常驻服务而言,
|
|
12
|
+
// 这意味着一次文件锁争用就会冻结所有并发会话/权限确认/SSE 流。因此新增异步版并把
|
|
13
|
+
// 「正好在请求路径上」的调用点迁过去(workspace 的 7 处);同步版保留给纯同步调用链。
|
|
14
|
+
//
|
|
15
|
+
// 另有一处**必须先修**的隐患:原可重入判据是**进程级 Set**。同步临界区不会 yield,
|
|
16
|
+
// 所以一直没出事;但异步临界区**会** yield——此时另一个任务拿同一把锁会被误判成"可重入"
|
|
17
|
+
// 而**并发**进入临界区,读-改-写互相覆盖。现在用 AsyncLocalStorage 把可重入限定在
|
|
18
|
+
// **同一条调用链**内:并发任务各持各的集合,嵌套调用复用同一份。
|
|
19
|
+
// 零依赖:仅 node:fs / node:path / node:crypto / node:async_hooks / Atomics.wait / timers/promises。
|
|
9
20
|
|
|
10
21
|
import fs from 'node:fs';
|
|
11
22
|
import path from 'node:path';
|
|
12
23
|
import crypto from 'node:crypto';
|
|
24
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
25
|
+
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
|
13
26
|
import { procAlive } from './proc.js';
|
|
14
27
|
|
|
15
28
|
export function atomicWriteFileSync(/** @type {string} */ target, /** @type {string|Buffer} */ data, /** @type {any} */ options = {}) {
|
|
@@ -30,86 +43,189 @@ export function atomicWriteJsonSync(/** @type {string} */ target, /** @type {any
|
|
|
30
43
|
atomicWriteFileSync(target, JSON.stringify(value, null, 2) + '\n', { mode });
|
|
31
44
|
}
|
|
32
45
|
|
|
33
|
-
// 极简互斥锁:O_EXCL 创建 lockfile;持锁期间执行 fn
|
|
34
|
-
//
|
|
35
|
-
// 可重入(P0-1 修复,v0.4.5):本进程已持该锁时直接执行 fn——O_EXCL 锁不可重入,此前
|
|
36
|
-
// killTask 持锁内调 patchTask 二次抢同一把锁自死锁 5s(tasks kill/pause/remove 全失效)。
|
|
46
|
+
// 极简互斥锁:O_EXCL 创建 lockfile;持锁期间执行 fn(同步/异步);异常/完成释放。
|
|
47
|
+
// 进程崩溃遗留的陈旧锁自动回收,避免永久卡死。
|
|
37
48
|
const sleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
|
38
49
|
const sleepMs = (/** @type {number} */ ms) => Atomics.wait(sleepBuf, 0, 0, ms);
|
|
39
|
-
const heldLocks = new Set(); // 本进程当前持有的锁路径(可重入判定)
|
|
40
50
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
// 可重入集合按**调用链**隔离(见文件头注释:异步临界区会 yield,进程级 Set 会让并发任务互相穿透)
|
|
52
|
+
const lockScope = new AsyncLocalStorage();
|
|
53
|
+
/** @param {(held: Set<string>) => any} fn */
|
|
54
|
+
function runWithHeld(fn) {
|
|
55
|
+
const held = lockScope.getStore();
|
|
56
|
+
if (held) return fn(held); // 已在同一条调用链里:复用同一份,嵌套即"可重入"
|
|
57
|
+
const fresh = new Set();
|
|
58
|
+
return lockScope.run(fresh, () => fn(fresh));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 尝试独占创建锁文件。EEXIST 表示别人持有(返回 false,由调用方决定等待或重试);
|
|
63
|
+
* 其它错误(权限/路径非法)**直接抛出**——那不是"有人在用",装成争用只会掩盖真问题。
|
|
64
|
+
* @param {string} lockPath
|
|
65
|
+
*/
|
|
66
|
+
function tryAcquire(lockPath) {
|
|
67
|
+
let fd;
|
|
68
|
+
try {
|
|
69
|
+
fd = fs.openSync(lockPath, 'wx');
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (/** @type {any} */ (err)?.code === 'EEXIST') return false;
|
|
72
|
+
throw err;
|
|
49
73
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
74
|
+
try {
|
|
75
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
76
|
+
} finally {
|
|
77
|
+
fs.closeSync(fd);
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @param {string} lockPath */
|
|
83
|
+
function releaseLock(lockPath) {
|
|
84
|
+
try {
|
|
85
|
+
fs.unlinkSync(lockPath);
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 判断能否回收一把陈旧锁(返回 true = 调用方应立刻重试)。
|
|
91
|
+
*
|
|
92
|
+
* v0.6.2(P2-7):陈旧判据是「**持有者 pid 已死**」优先,而不是只看 mtime:
|
|
93
|
+
* · 只看 mtime 会带来两个反向问题:① 崩溃后要白等满 staleMs 才允许回收(配小 timeout 就是死区);
|
|
94
|
+
* ② 某个 fn 本身耗时超过 staleMs(大文件重写、慢盘)时,别人会把**仍然活着**的锁判成陈旧并回收
|
|
95
|
+
* ——互斥直接失效,且没有任何补救。
|
|
96
|
+
* · 锁内容本来就写着 {pid, at},现成的判据要用上:持有者还活着(哪怕 fn 跑了很久)**绝不回收**。
|
|
97
|
+
* 读不到持有者信息(老格式/内容损坏)时才退回「mtime 超过 staleMs」。
|
|
98
|
+
* @param {string} lockPath @param {number} staleMs
|
|
99
|
+
*/
|
|
100
|
+
function reclaimIfStale(lockPath, staleMs) {
|
|
101
|
+
let st;
|
|
102
|
+
try {
|
|
103
|
+
st = fs.statSync(lockPath);
|
|
104
|
+
} catch {
|
|
105
|
+
return true; // 锁文件刚被释放:立刻重试
|
|
106
|
+
}
|
|
107
|
+
let holderPid = 0;
|
|
108
|
+
try {
|
|
109
|
+
holderPid = Number(JSON.parse(fs.readFileSync(lockPath, 'utf8'))?.pid) || 0;
|
|
110
|
+
} catch {}
|
|
111
|
+
const holderAlive = holderPid > 0 ? procAlive(holderPid) : null;
|
|
112
|
+
const reclaimable = holderAlive === false || (holderAlive === null && Date.now() - st.mtimeMs > staleMs);
|
|
113
|
+
if (!reclaimable) return false;
|
|
114
|
+
// TOCTOU 防护(OfficeACE 报告):unlink 前读锁内容并二次 stat 比对,
|
|
115
|
+
// 防两个进程同时判定陈旧、后者误删前者刚创建的新锁(互斥失效)
|
|
116
|
+
try {
|
|
117
|
+
const before = fs.readFileSync(lockPath, 'utf8');
|
|
118
|
+
const st2 = fs.statSync(lockPath);
|
|
119
|
+
if (st2.mtimeMs === st.mtimeMs && before === fs.readFileSync(lockPath, 'utf8')) {
|
|
120
|
+
fs.unlinkSync(lockPath);
|
|
121
|
+
}
|
|
122
|
+
} catch {}
|
|
123
|
+
return true; // 无论是否回收成功都重试一次(若锁刚被他人更新则继续等待)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 锁的两个时间参数(v0.6.2 依实测重新定过):
|
|
127
|
+
//
|
|
128
|
+
// ① **必须 timeoutMs > staleMs**。曾是 5000/15000——等待方 5 秒就抛超时,而陈旧锁要 15 秒才
|
|
129
|
+
// 允许回收,中间 10 秒是纯**死区**:持锁方一旦崩溃,这段内所有写方必然全部失败。
|
|
130
|
+
// ② 现在取 **5000 / 4000**:死区没了,且**等锁的最坏冻结从 20 秒降到 5 秒**。
|
|
131
|
+
// 依据是实测(本机):纯状态的临界区极短——小文件读-改-写 **0.13ms**,
|
|
132
|
+
// 连最重的 cache-stats 轮转(1.43MB / 2 万行解析 + 重写 1 万行)也只有 **6ms**。
|
|
133
|
+
// 5 秒是实测最坏值的约 800 倍,正常争用绝不会误判超时;**只有**「持有者活着但卡死」
|
|
134
|
+
// 或「锁内容读不出 pid 且超过 staleMs」才会等这么久。
|
|
135
|
+
// ③ 两个值都可用环境变量覆盖,便于诊断与测试(例如调小 staleMs 以便立刻回收陈旧锁)。
|
|
136
|
+
const ENV_TIMEOUT = Number(process.env.MINGDAO_LOCK_TIMEOUT_MS);
|
|
137
|
+
const ENV_STALE = Number(process.env.MINGDAO_LOCK_STALE_MS);
|
|
138
|
+
const DEFAULT_TIMEOUT_MS = Number.isFinite(ENV_TIMEOUT) && ENV_TIMEOUT > 0 ? ENV_TIMEOUT : 5000;
|
|
139
|
+
const DEFAULT_STALE_MS = Number.isFinite(ENV_STALE) && ENV_STALE > 0 ? ENV_STALE : 4000;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 锁超时的统一文案:**说清锁文件在哪、怎么看持有者、什么时候可以删**。
|
|
143
|
+
* 只报一句"获取文件锁超时"的话,用户除了重试什么也做不了。
|
|
144
|
+
* @param {string} lockPath @param {number} timeoutMs
|
|
145
|
+
*/
|
|
146
|
+
function lockTimeoutError(lockPath, timeoutMs) {
|
|
147
|
+
return new Error(
|
|
148
|
+
`获取文件锁超时(${(timeoutMs / 1000).toFixed(1)} 秒,${lockPath}):可能有其他进程长时间占用或已卡死。\n` +
|
|
149
|
+
` 排查:查看该 .lock 文件内容(形如 {"pid":123,"at":…}),确认那个 pid 是否还活着;` +
|
|
150
|
+
`若不是活着的 mingdao 进程,删除该文件后重试即可(陈旧锁也会在 ${DEFAULT_STALE_MS / 1000} 秒后自动回收)。`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function withFileLockSync(/** @type {string} */ lockPath, /** @type {() => any} */ fn, { timeoutMs = DEFAULT_TIMEOUT_MS, staleMs = DEFAULT_STALE_MS } = {}) {
|
|
155
|
+
return runWithHeld((held) => {
|
|
156
|
+
// 可重入:同一条调用链内已持该锁 → 直接执行,不再二次抢锁
|
|
157
|
+
if (held.has(lockPath)) return fn();
|
|
158
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
159
|
+
const t0 = Date.now();
|
|
160
|
+
for (;;) {
|
|
161
|
+
// v0.4.7:区分「抢锁阶段」与「执行 fn 阶段」。此前 fn 自身抛出的 EEXIST 也会落进下方的
|
|
162
|
+
// 锁重试分支,而 finally 已释放锁 → 重抢成功后再次执行 fn → 再次抛出 → **同步死循环**
|
|
163
|
+
// (100% CPU,连超时分支都不可达)。acquiring 在 fn 开始执行前置 false。
|
|
164
|
+
let acquiring = true;
|
|
66
165
|
try {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
166
|
+
if (!tryAcquire(lockPath)) {
|
|
167
|
+
const e = /** @type {any} */ (new Error('EEXIST'));
|
|
168
|
+
e.code = 'EEXIST';
|
|
169
|
+
throw e;
|
|
170
|
+
}
|
|
171
|
+
held.add(lockPath);
|
|
172
|
+
acquiring = false; // 锁已持有:此后 fn 的任何异常都不得进入锁重试分支
|
|
70
173
|
try {
|
|
71
|
-
|
|
72
|
-
}
|
|
174
|
+
return fn();
|
|
175
|
+
} finally {
|
|
176
|
+
held.delete(lockPath);
|
|
177
|
+
releaseLock(lockPath);
|
|
178
|
+
}
|
|
179
|
+
} catch (err) {
|
|
180
|
+
if (!acquiring) throw err; // fn 自身的异常绝不进入锁重试
|
|
181
|
+
if (/** @type {any} */ (err).code !== 'EEXIST') throw err;
|
|
182
|
+
if (reclaimIfStale(lockPath, staleMs)) continue;
|
|
183
|
+
if (Date.now() - t0 > timeoutMs) {
|
|
184
|
+
throw lockTimeoutError(lockPath, timeoutMs);
|
|
185
|
+
}
|
|
186
|
+
sleepMs(25);
|
|
73
187
|
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 异步版互斥:语义与 withFileLockSync **完全一致**(同一套 tryAcquire / reclaimIfStale /
|
|
194
|
+
* 可重入规则、同样的超时与错误文案),唯一区别是等待时 `await sleepAsync()` —— 让出事件循环,
|
|
195
|
+
* 因此**不会冻结 WebUI**。事件循环敏感的调用点(请求路径)应当用它。
|
|
196
|
+
*
|
|
197
|
+
* 为什么值得单独一个函数而不是把同步版改成异步:调用链里不少地方本身就是同步的
|
|
198
|
+
* (CLI 一次性命令、调度守护进程内部的读-改-写),强行异步化会把 async 传染到 24 个调用点,
|
|
199
|
+
* 回归风险大于收益。做法是**逐个判断**:请求路径上的迁移,纯同步链保留同步版。
|
|
200
|
+
* @param {string} lockPath @param {() => any} fn
|
|
201
|
+
*/
|
|
202
|
+
export async function withFileLock(/** @type {string} */ lockPath, /** @type {() => any} */ fn, { timeoutMs = DEFAULT_TIMEOUT_MS, staleMs = DEFAULT_STALE_MS, pollMs = 25 } = {}) {
|
|
203
|
+
return runWithHeld(async (held) => {
|
|
204
|
+
if (held.has(lockPath)) return fn();
|
|
205
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
206
|
+
const t0 = Date.now();
|
|
207
|
+
for (;;) {
|
|
208
|
+
if (tryAcquire(lockPath)) {
|
|
209
|
+
held.add(lockPath);
|
|
87
210
|
try {
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (reclaimable) {
|
|
95
|
-
// TOCTOU 防护(OfficeACE 报告):unlink 前读锁内容并二次 stat 比对,
|
|
96
|
-
// 防两个进程同时判定陈旧、后者误删前者刚创建的新锁(互斥失效)
|
|
97
|
-
try {
|
|
98
|
-
const before = fs.readFileSync(lockPath, 'utf8');
|
|
99
|
-
const st2 = fs.statSync(lockPath);
|
|
100
|
-
if (st2.mtimeMs === st.mtimeMs && before === fs.readFileSync(lockPath, 'utf8')) {
|
|
101
|
-
fs.unlinkSync(lockPath);
|
|
102
|
-
}
|
|
103
|
-
} catch {}
|
|
104
|
-
continue; // 无论是否回收成功都重试一次(若锁刚被他人更新则继续等待)
|
|
211
|
+
return await fn();
|
|
212
|
+
} finally {
|
|
213
|
+
// 注意:这里必须放在 try/finally 里而不是靠外层 catch——
|
|
214
|
+
// fn 的异常要原样抛出,绝不能被误当成"抢锁失败"而重跑临界区
|
|
215
|
+
held.delete(lockPath);
|
|
216
|
+
releaseLock(lockPath);
|
|
105
217
|
}
|
|
106
|
-
} catch {
|
|
107
|
-
continue; // 锁文件刚被释放
|
|
108
218
|
}
|
|
219
|
+
if (reclaimIfStale(lockPath, staleMs)) continue;
|
|
109
220
|
if (Date.now() - t0 > timeoutMs) {
|
|
110
|
-
throw
|
|
221
|
+
throw lockTimeoutError(lockPath, timeoutMs);
|
|
111
222
|
}
|
|
112
|
-
|
|
223
|
+
await sleepAsync(pollMs);
|
|
113
224
|
}
|
|
114
|
-
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 同步/异步锁的默认超时与陈旧阈值(导出供诊断与测试断言「上限有界」)。 */
|
|
229
|
+
export function lockDefaults() {
|
|
230
|
+
return { timeoutMs: DEFAULT_TIMEOUT_MS, staleMs: DEFAULT_STALE_MS };
|
|
115
231
|
}
|
|
@@ -12,7 +12,7 @@ export async function handleWorkspace(/** @type {any} */ cmd, /** @type {any} */
|
|
|
12
12
|
process.exitCode = 1;
|
|
13
13
|
return true;
|
|
14
14
|
}
|
|
15
|
-
const r = addWorkspace(name, args[2]);
|
|
15
|
+
const r = await addWorkspace(name, args[2]);
|
|
16
16
|
if (r.error) {
|
|
17
17
|
console.log('[错误] ' + r.error);
|
|
18
18
|
process.exitCode = 1;
|
|
@@ -29,7 +29,7 @@ export async function handleWorkspace(/** @type {any} */ cmd, /** @type {any} */
|
|
|
29
29
|
}
|
|
30
30
|
// v0.6.2:removeWorkspace 现在可能返回 {error}(注册表写失败)。
|
|
31
31
|
// 只判真值会把对象当成成功,把「写失败」说成「✓ 已移除」——那正是本批要消灭的假成功。
|
|
32
|
-
const rm = removeWorkspace(name);
|
|
32
|
+
const rm = await removeWorkspace(name);
|
|
33
33
|
if (rm === true) console.log(`✓ 已移除 ${name}`);
|
|
34
34
|
else if (rm && /** @type {any} */ (rm).error) {
|
|
35
35
|
console.log('[错误] ' + /** @type {any} */ (rm).error);
|
|
@@ -63,7 +63,7 @@ export async function handleWorkspace(/** @type {any} */ cmd, /** @type {any} */
|
|
|
63
63
|
process.exitCode = 1;
|
|
64
64
|
return true;
|
|
65
65
|
}
|
|
66
|
-
touchWorkspace(name);
|
|
66
|
+
await touchWorkspace(name);
|
|
67
67
|
console.log(`✓ 工作空间 ${name}:${p}`);
|
|
68
68
|
console.log(` 快速进入:cd "$(mingdao workspace path ${name})"(建议做成 shell 函数/别名,如 mdw() { cd "$(mingdao workspace path "$1")"; })`);
|
|
69
69
|
return true;
|
package/src/providers/index.js
CHANGED
|
@@ -17,10 +17,30 @@ import { mingdaoHome } from '../config.js';
|
|
|
17
17
|
import { resolveApiKey, getStoredKey } from '../credentials.js';
|
|
18
18
|
import { isLocalBaseUrl } from '../model-caps.js';
|
|
19
19
|
|
|
20
|
+
// v0.6.3(下游 Dify 工作流反馈):`customModels` 的条目其实是**两种东西**,此前混为一谈——
|
|
21
|
+
// · **声明式端点**:写了 baseUrl / envKey / apiKey / headers 等传输字段 → 确实是"自定义
|
|
22
|
+
// OpenAI 兼容端点",优先于内置预设(原行为,不变);
|
|
23
|
+
// · **纯能力覆盖**:只写 vision / tokenizer / contextWindow / maxOutputTokens / local,
|
|
24
|
+
// 或只给一个 provider 路由提示 → 它只是给**已有模型**补能力声明,
|
|
25
|
+
// **不该劫持 transport**。
|
|
26
|
+
// 为什么必须区分:下游为了让图片能发出去,只能往 customModels 里加一条 `vision: true`;
|
|
27
|
+
// 而原实现只要 cm 存在就返回 `custom:{name}` + openai-compatible,
|
|
28
|
+
// 于是把他们的**自定义 Provider 模块整个绕开**(请求发到 baseUrl 而不是他们的 Dify 适配器)。
|
|
29
|
+
// 换句话说:「打开一个能力开关」不该改变「请求发给谁」。
|
|
30
|
+
const CM_TRANSPORT_KEYS = ['baseUrl', 'apiKey', 'envKey', 'headers', 'path', 'kind'];
|
|
31
|
+
/** @param {any} cm */
|
|
32
|
+
function isEndpointDeclaration(cm) {
|
|
33
|
+
if (!cm || typeof cm !== 'object') return false;
|
|
34
|
+
return CM_TRANSPORT_KEYS.some((k) => {
|
|
35
|
+
const v = cm[k];
|
|
36
|
+
return v !== undefined && v !== null && !(typeof v === 'string' && v.trim() === '');
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
20
40
|
export function resolveProviderConfig(/** @type {any} */ cfg, /** @type {any} */ modelName) {
|
|
21
41
|
// 自定义模型(config.customModels,WebUI 可增删改):优先于内置预设
|
|
22
42
|
const cm = cfg?.customModels?.[modelName];
|
|
23
|
-
if (cm) {
|
|
43
|
+
if (cm && isEndpointDeclaration(cm)) {
|
|
24
44
|
// P1 修复(v0.4.6):自定义模型的密钥解析不得成为「任意环境变量外泄」通道。
|
|
25
45
|
// 此前 envKeys = [cm.envKey, 'MINGDAO_API_KEY'],而 envKey 与 baseUrl 都完全由调用方指定
|
|
26
46
|
// (WebUI 的 addCustom 接受任意输入)——于是任何能加一个自定义模型的人,都能把宿主环境里的
|
|
@@ -55,7 +75,9 @@ export function resolveProviderConfig(/** @type {any} */ cfg, /** @type {any} */
|
|
|
55
75
|
};
|
|
56
76
|
}
|
|
57
77
|
const preset = modelPreset(modelName);
|
|
58
|
-
|
|
78
|
+
// 纯能力覆盖里可以给一个**路由提示**:customModels.<name>.provider = 'dify'
|
|
79
|
+
// → 走那个 provider(自定义模块会在 createProvider 里被加载),而不是退到默认 provider。
|
|
80
|
+
const name = (cm && typeof cm.provider === 'string' && cm.provider.trim()) || preset?.provider || cfg?.provider || 'deepseek';
|
|
59
81
|
const pp = providerPreset(name) || { kind: 'openai-compatible' };
|
|
60
82
|
const baseUrl = cfg?.baseUrl || pp.baseUrl || '';
|
|
61
83
|
const apiKey = resolveApiKey(cfg, name, pp.envKey);
|
|
@@ -69,6 +91,52 @@ export function resolveProviderConfig(/** @type {any} */ cfg, /** @type {any} */
|
|
|
69
91
|
};
|
|
70
92
|
}
|
|
71
93
|
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// 视觉能力解析(v0.6.3,下游 Dify 工作流反馈)
|
|
96
|
+
//
|
|
97
|
+
// 原门控只看「内置预设 supportsVision」或「customModels.<name>.vision」,
|
|
98
|
+
// 于是**自定义 Provider**(自己的 Dify/网关适配器)永远被判为不支持图片——
|
|
99
|
+
// 而那些 Provider 明明支持视觉。现在把自定义 Provider 模块的**静态声明**也算进来。
|
|
100
|
+
//
|
|
101
|
+
// 声明方式(任选其一,写在 ~/.mingdao/providers/<name>.mjs 里):
|
|
102
|
+
// export const supportsVision = true;
|
|
103
|
+
// export const capabilities = { vision: true };
|
|
104
|
+
// 只读**静态导出**,不调用 createProvider()——后者可能有副作用(起进程/建连接)。
|
|
105
|
+
// 结果按文件 mtime 缓存,避免每轮都 import。
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
/** @type {Map<string, {mtimeMs: number, vision: boolean}>} */
|
|
108
|
+
const visionProbeCache = new Map();
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 该模型是否支持图片输入。顺序:显式声明 > 内置预设 > 自定义 Provider 的静态声明。
|
|
112
|
+
* @param {any} cfg @param {any} modelName
|
|
113
|
+
* @returns {Promise<boolean>}
|
|
114
|
+
*/
|
|
115
|
+
export async function resolveVisionSupport(cfg, modelName) {
|
|
116
|
+
const cm = cfg?.customModels?.[modelName];
|
|
117
|
+
// ① 显式声明优先(true/false 都算数——写 false 就是明确关闭)
|
|
118
|
+
if (cm && typeof cm.vision === 'boolean') return cm.vision;
|
|
119
|
+
// ② 内置预设
|
|
120
|
+
if (modelPreset(modelName)?.supportsVision === true) return true;
|
|
121
|
+
// ③ 自定义 Provider 模块的静态声明
|
|
122
|
+
try {
|
|
123
|
+
const pc = resolveProviderConfig(cfg, modelName);
|
|
124
|
+
if (!pc.isCustom || pc.name.includes(':')) return false;
|
|
125
|
+
const file = path.join(mingdaoHome(), 'providers', pc.name + '.mjs');
|
|
126
|
+
if (!fs.existsSync(file)) return false;
|
|
127
|
+
const mtimeMs = fs.statSync(file).mtimeMs;
|
|
128
|
+
const hit = visionProbeCache.get(pc.name);
|
|
129
|
+
if (hit && hit.mtimeMs === mtimeMs) return hit.vision;
|
|
130
|
+
const mod = await import(pathToFileURL(file).href + `?v=${mtimeMs}`);
|
|
131
|
+
const vision = mod?.supportsVision === true || mod?.capabilities?.vision === true;
|
|
132
|
+
visionProbeCache.set(pc.name, { mtimeMs, vision });
|
|
133
|
+
return vision;
|
|
134
|
+
} catch {
|
|
135
|
+
// 探不动就当不支持(门控是"能不能发图",宁可保守);但**不抛**——不能因为探测失败让整轮崩
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
72
140
|
const sleep = (/** @type {any} */ ms) => new Promise((r) => setTimeout(r, ms));
|
|
73
141
|
|
|
74
142
|
function isTransient(/** @type {any} */ err) {
|
package/src/tasks.js
CHANGED
|
@@ -247,11 +247,17 @@ export async function flushKillEscalation() {
|
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
export function killTask(/** @type {any} */ home, /** @type {any} */ id) {
|
|
250
|
-
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
250
|
+
// v0.6.2(P2-7 阻塞面):进程操作全部移到锁**外**,临界区只剩状态读-改-写。
|
|
251
|
+
//
|
|
252
|
+
// 原实现把 killTaskInner 整个包在锁里,而它内部会:
|
|
253
|
+
// · pidOwnedBy() —— 非 Linux 上回退到**同步** execFileSync('ps');
|
|
254
|
+
// · killTree() —— Windows 上走**同步** spawnSync('taskkill');
|
|
255
|
+
// 于是临界区里夹着外部进程调用,持锁时间从毫秒级变成百毫秒级,**每个等锁的人都被拖住**。
|
|
256
|
+
// 实测(本机)纯状态读-改-写约 0.13ms、最坏的 cache-stats 轮转也只有 6ms——
|
|
257
|
+
// 也就是说,锁被长时间持有完全是自己把慢操作放进去造成的。
|
|
258
|
+
//
|
|
259
|
+
// 语义不变:状态写回仍在锁内(与 worker 的终态写互斥),P3 T20 的「killed 优先」保护
|
|
260
|
+
// 仍在 patchTask 里;把进程操作提前到锁外不影响任何一条竞态路径的最终状态。
|
|
255
261
|
const t = readTask(home, id);
|
|
256
262
|
if (!t) return false;
|
|
257
263
|
if (t.status === 'running' && t.pid) {
|
|
@@ -269,8 +275,14 @@ function killTaskInner(/** @type {any} */ home, /** @type {any} */ id) {
|
|
|
269
275
|
pendingKill = escalateKill(t.pid);
|
|
270
276
|
}
|
|
271
277
|
}
|
|
272
|
-
|
|
273
|
-
return
|
|
278
|
+
// 状态写回:仍在跨进程锁内(只做读-改-写,毫秒级)
|
|
279
|
+
return killTaskInner(home, id) !== null;
|
|
280
|
+
}
|
|
281
|
+
/** 临界区内的状态写回(进程部分见 killTask,已移到锁外) */
|
|
282
|
+
function killTaskInner(/** @type {any} */ home, /** @type {any} */ id) {
|
|
283
|
+
const t = readTask(home, id);
|
|
284
|
+
if (!t) return null;
|
|
285
|
+
return patchTask(home, id, { status: 'killed', durationMs: t.durationMs ?? Date.now() - t.startedAt });
|
|
274
286
|
}
|
|
275
287
|
|
|
276
288
|
const MARK = { running: '▶', done: '✓', failed: '✖', killed: '■' };
|
package/src/tools/bash.js
CHANGED
|
@@ -86,9 +86,89 @@ function foldRepeats(/** @type {any} */ s) {
|
|
|
86
86
|
}
|
|
87
87
|
return out.join('\n');
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* 子进程输出解码:**一次**解整段字节,而不是逐块 `toString()`。
|
|
91
|
+
*
|
|
92
|
+
* 两处乱码根因(v0.6.3,桌面版实测):
|
|
93
|
+
* ① 逐块解码:中文 3 字节被管道切成两半 → 两半各自解出 U+FFFD;
|
|
94
|
+
* ② Windows 上本工具走 `cmd.exe /d /s /c`,输出是 OEM 代码页(中文 = GBK/CP936),
|
|
95
|
+
* 按 UTF-8 解就是花屏。故严格 UTF-8 失败时用 GBK 再解一次。
|
|
96
|
+
* 导出的目的是让这两类乱码能被**确定性**测到——②只在 Windows 出现,CI 上跑不出真环境。
|
|
97
|
+
* @param {Buffer} buf
|
|
98
|
+
*/
|
|
99
|
+
export function decodeProcessOutput(buf) {
|
|
100
|
+
if (!buf || !buf.length) return '';
|
|
101
|
+
try {
|
|
102
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
|
103
|
+
} catch {
|
|
104
|
+
try {
|
|
105
|
+
return new TextDecoder('gbk').decode(buf);
|
|
106
|
+
} catch {
|
|
107
|
+
return buf.toString('utf8');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 子进程输出采集器(**导出以便确定性测试**)。
|
|
114
|
+
*
|
|
115
|
+
* 为什么单独成一个原语:乱码的两个根因都藏在这里,而"端到端跑一个大输出"根本测不准——
|
|
116
|
+
* 输出上限(约 60KB)比一个管道块(64KB)还小,保留的尾部常常落在**单块之内**,
|
|
117
|
+
* 跨块解码的缺陷于是照样通过(第一版回归就是这么假绿的,靠变异验证才发现)。
|
|
118
|
+
* 把 push/text 暴露出来,测试就能**人工把汉字切成两半**喂进去。
|
|
119
|
+
*
|
|
120
|
+
* @param {number} maxBytes 只保留尾部这么多字节(超出丢弃头部)
|
|
121
|
+
*/
|
|
122
|
+
export function createOutputCapture(maxBytes) {
|
|
123
|
+
/** @type {{chunks: Buffer[], bytes: number}} */
|
|
124
|
+
const st = { chunks: [], bytes: 0 };
|
|
125
|
+
const snapBoundary = () => {
|
|
126
|
+
// **对齐到字符边界**:按字节裁会把 UTF-8 从字符中间切断,于是"整段严格解码"必然失败
|
|
127
|
+
// → 误触发 GBK 回退 → 反而解成花屏(本修复第一版的真实缺陷,被单测抓出来的)。
|
|
128
|
+
// UTF-8 的续字节形如 10xxxxxx,丢掉它们即可让缓冲区从**前导字节**开始。
|
|
129
|
+
while (st.chunks.length && st.chunks[0].length && (st.chunks[0][0] & 0xc0) === 0x80) {
|
|
130
|
+
st.chunks[0] = st.chunks[0].subarray(1);
|
|
131
|
+
st.bytes -= 1;
|
|
132
|
+
if (!st.chunks[0].length) st.chunks.shift();
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
return {
|
|
136
|
+
/** @param {Buffer} d */
|
|
137
|
+
push(d) {
|
|
138
|
+
st.chunks.push(d);
|
|
139
|
+
st.bytes += d.length;
|
|
140
|
+
while (st.bytes > maxBytes && st.chunks.length) {
|
|
141
|
+
const first = st.chunks[0];
|
|
142
|
+
const drop = Math.min(first.length, st.bytes - maxBytes);
|
|
143
|
+
if (drop >= first.length) {
|
|
144
|
+
st.chunks.shift();
|
|
145
|
+
st.bytes -= first.length;
|
|
146
|
+
} else {
|
|
147
|
+
st.chunks[0] = first.subarray(drop);
|
|
148
|
+
st.bytes -= drop;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
snapBoundary();
|
|
152
|
+
},
|
|
153
|
+
/** 结束时**一次**解码整段字节(绝不能逐块 toString:会把汉字劈成 U+FFFD) */
|
|
154
|
+
text() {
|
|
155
|
+
return st.chunks.length ? decodeProcessOutput(Buffer.concat(st.chunks)) : '';
|
|
156
|
+
},
|
|
157
|
+
get bytes() {
|
|
158
|
+
return st.bytes;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
89
163
|
function tail(/** @type {any} */ s, /** @type {any} */ n) {
|
|
90
164
|
const folded = foldRepeats(stripAnsi(s));
|
|
91
|
-
|
|
165
|
+
if (folded.length <= n) return folded;
|
|
166
|
+
let t = folded.slice(-n);
|
|
167
|
+
// 不要把**代理对**劈成两半:BMP 以外的字符(emoji 等)被切一半会渲染成 U+FFFD。
|
|
168
|
+
// 与 context.js 的同类问题同源(登记簿 BUG-079)。
|
|
169
|
+
const c0 = t.charCodeAt(0);
|
|
170
|
+
if (c0 >= 0xdc00 && c0 <= 0xdfff) t = t.slice(1);
|
|
171
|
+
return `…[输出过长,已截断头部]\n${t}`;
|
|
92
172
|
}
|
|
93
173
|
|
|
94
174
|
export function runBash(/** @type {any} */ args, /** @type {any} */ ctx) {
|
|
@@ -140,15 +220,17 @@ export function runBash(/** @type {any} */ args, /** @type {any} */ ctx) {
|
|
|
140
220
|
detached: true, // POSIX:自成进程组,超时/结束可整组清理,孙进程不成孤儿
|
|
141
221
|
...spawnOpts({ piped: true }), // Windows:不 detach + 隐藏控制台(否则每次命令弹一个终端)
|
|
142
222
|
});
|
|
143
|
-
|
|
144
|
-
|
|
223
|
+
// v0.6.3(桌面版 bash 输出乱码,两处根因):
|
|
224
|
+
// ① **不能逐块 `toString()`**:中文 3 字节,被管道切成两半时两半各自解出 U+FFFD(乱码)。
|
|
225
|
+
// 改为按 **Buffer** 累积、结束时**一次**解码。
|
|
226
|
+
// ② **Windows 上这里走的是 `cmd.exe /d /s /c`**,其输出是 OEM 代码页(中文 = GBK/CP936),
|
|
227
|
+
// 按 UTF-8 解就是花屏("回访"→"»Ø·Ã")。故严格 UTF-8 解失败时用 GBK 再解一次。
|
|
228
|
+
// Node 官方构建带 full-icu,`new TextDecoder('gbk')` 可用;不支持时退回非严格 UTF-8。
|
|
229
|
+
const MAX_BYTES = MAX_OUTPUT * 2; // 按字节预留(中文 1 字 ≈ 3 字节)
|
|
230
|
+
const outCap = createOutputCapture(MAX_BYTES);
|
|
231
|
+
const errCap = createOutputCapture(MAX_BYTES);
|
|
145
232
|
let done = false;
|
|
146
233
|
let timedOut = false;
|
|
147
|
-
// 输出增量截断:超长输出只保留尾部,避免内存无限累积
|
|
148
|
-
const cap = (/** @type {any} */ s, /** @type {any} */ d) => {
|
|
149
|
-
const t = s + d;
|
|
150
|
-
return t.length > MAX_OUTPUT * 2 ? t.slice(-MAX_OUTPUT * 2) : t;
|
|
151
|
-
};
|
|
152
234
|
const killGroup = (/** @type {any} */ sig) => {
|
|
153
235
|
try {
|
|
154
236
|
process.kill(-(/** @type {any} */ (child)).pid, sig);
|
|
@@ -175,17 +257,13 @@ export function runBash(/** @type {any} */ args, /** @type {any} */ ctx) {
|
|
|
175
257
|
timedOut,
|
|
176
258
|
sandbox,
|
|
177
259
|
note: timedOut ? '命令超时,已强杀进程组' : '输出管道未释放,已清理子进程组',
|
|
178
|
-
stdout: tail(
|
|
179
|
-
stderr: tail(
|
|
260
|
+
stdout: tail(outCap.text(), MAX_OUTPUT),
|
|
261
|
+
stderr: tail(errCap.text(), MAX_OUTPUT),
|
|
180
262
|
});
|
|
181
263
|
}, timeoutSec * 1000 + 3000);
|
|
182
264
|
|
|
183
|
-
child.stdout.on('data', (d) =>
|
|
184
|
-
|
|
185
|
-
});
|
|
186
|
-
child.stderr.on('data', (d) => {
|
|
187
|
-
err = cap(err, d);
|
|
188
|
-
});
|
|
265
|
+
child.stdout.on('data', (d) => outCap.push(d));
|
|
266
|
+
child.stderr.on('data', (d) => errCap.push(d));
|
|
189
267
|
child.on('error', (e) => {
|
|
190
268
|
if (done) return;
|
|
191
269
|
done = true;
|
|
@@ -204,8 +282,8 @@ export function runBash(/** @type {any} */ args, /** @type {any} */ ctx) {
|
|
|
204
282
|
timedOut,
|
|
205
283
|
sandbox,
|
|
206
284
|
note: note || undefined,
|
|
207
|
-
stdout: tail(
|
|
208
|
-
stderr: tail(
|
|
285
|
+
stdout: tail(outCap.text(), MAX_OUTPUT),
|
|
286
|
+
stderr: tail(errCap.text(), MAX_OUTPUT),
|
|
209
287
|
});
|
|
210
288
|
});
|
|
211
289
|
});
|
package/src/web/attachments.js
CHANGED
|
@@ -23,7 +23,13 @@ export function buildUserContent(/** @type {any} */ message, /** @type {any} */
|
|
|
23
23
|
return { error: `图片过大:${a.name || '未命名'}(单张 ≤5MB)` };
|
|
24
24
|
}
|
|
25
25
|
if (!visionSupported) {
|
|
26
|
-
return {
|
|
26
|
+
return {
|
|
27
|
+
error:
|
|
28
|
+
'当前模型不支持图片输入。三种开启方式:' +
|
|
29
|
+
'① 切换到支持视觉的内置模型(deepseek-v4-flash-vision-exp);' +
|
|
30
|
+
'② 在 config.customModels.<模型名>.vision = true 显式声明(纯能力声明,不会改变请求发往哪里);' +
|
|
31
|
+
'③ 自定义 Provider 模块(~/.mingdao/providers/<provider>.mjs)里 export const supportsVision = true。',
|
|
32
|
+
};
|
|
27
33
|
}
|
|
28
34
|
imageParts.push({ type: 'image_url', image_url: { url: dataUrl } });
|
|
29
35
|
persistParts.push(`[图片:${a.name || '未命名'}]`);
|
|
@@ -50,7 +50,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
50
50
|
const ws = workspaceForDir(wsDir);
|
|
51
51
|
if (wsDir && path.resolve(wsDir) !== path.resolve(state.workingDir)) {
|
|
52
52
|
state.workingDir = wsDir; // 审计 P2-5:仅在目录不同时才切换,减少全局状态抖动
|
|
53
|
-
if (ws) touchWorkspace(ws.name);
|
|
53
|
+
if (ws) await touchWorkspace(ws.name);
|
|
54
54
|
}
|
|
55
55
|
json(res, 200, {
|
|
56
56
|
ok: true,
|
|
@@ -74,13 +74,13 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
74
74
|
const title = sanitizeTitle(String(body.title || '会话'));
|
|
75
75
|
const renamed = renameSessionFile(fs, path, home, { file: full }, title);
|
|
76
76
|
if (!renamed) return json(res, 500, { error: '重命名失败(可能存在同名会话)' });
|
|
77
|
-
moveSessionWorkspace(file, path.basename(renamed));
|
|
77
|
+
await moveSessionWorkspace(file, path.basename(renamed));
|
|
78
78
|
return json(res, 200, { ok: true, file: path.basename(renamed) });
|
|
79
79
|
}
|
|
80
80
|
if (body.action === 'delete') {
|
|
81
81
|
try {
|
|
82
82
|
fs.unlinkSync(full);
|
|
83
|
-
removeSessionWorkspace(file);
|
|
83
|
+
await removeSessionWorkspace(file);
|
|
84
84
|
return json(res, 200, { ok: true });
|
|
85
85
|
} catch (/** @type {any} */ err) {
|
|
86
86
|
return json(res, 500, { error: String(err?.message || err) });
|
|
@@ -76,12 +76,12 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
76
76
|
return json(res, 400, { error: `无法创建目录:${target}(${e.message})` });
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
-
const r = addWorkspace(name, target);
|
|
79
|
+
const r = await addWorkspace(name, target);
|
|
80
80
|
if (r.error) return json(res, 400, { error: r.error });
|
|
81
81
|
return json(res, 200, { ok: true, name: r.name, dir: r.dir, created: body.create !== false });
|
|
82
82
|
}
|
|
83
83
|
if (body.action === 'rename') {
|
|
84
|
-
const r = renameWorkspace(name, body.newName);
|
|
84
|
+
const r = await renameWorkspace(name, body.newName);
|
|
85
85
|
if (r.error) return json(res, 400, { error: r.error });
|
|
86
86
|
return json(res, 200, { ok: true, name: r.name });
|
|
87
87
|
}
|
|
@@ -96,7 +96,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
96
96
|
error: `目录 ${t2} 不在允许范围内(家目录 / 启动目录 / 当前工作目录 / web.browseRoots)。确需切换请配置 web.allowAnyWorkspaceDir: true。`,
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
|
-
const r = setWorkspaceDir(name, body.dir);
|
|
99
|
+
const r = await setWorkspaceDir(name, body.dir);
|
|
100
100
|
if (r.error) return json(res, 400, { error: r.error });
|
|
101
101
|
}
|
|
102
102
|
const dir = workspacePath(name);
|
|
@@ -106,15 +106,15 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
106
106
|
} catch (/** @type {any} */ e) {
|
|
107
107
|
return json(res, 400, { error: `无法创建目录:${dir}(${e.message})` });
|
|
108
108
|
}
|
|
109
|
-
touchWorkspace(name);
|
|
109
|
+
await touchWorkspace(name);
|
|
110
110
|
state.workingDir = dir;
|
|
111
111
|
// 不再 process.chdir:运行中任务的 cwd 在创建时已固定,全局切换只影响新会话
|
|
112
|
-
if (body.file) setSessionWorkspace(String(body.file), dir, /** @type {any} */ (name));
|
|
112
|
+
if (body.file) await setSessionWorkspace(String(body.file), dir, /** @type {any} */ (name));
|
|
113
113
|
return json(res, 200, { ok: true, name, dir, current: name });
|
|
114
114
|
}
|
|
115
115
|
if (body.action === 'remove') {
|
|
116
116
|
if (!name) return json(res, 400, { error: '缺少名称' });
|
|
117
|
-
const rm = removeWorkspace(name);
|
|
117
|
+
const rm = await removeWorkspace(name);
|
|
118
118
|
if (rm && /** @type {any} */ (rm).error) return json(res, 500, { error: /** @type {any} */ (rm).error });
|
|
119
119
|
return json(res, 200, { ok: rm === true });
|
|
120
120
|
}
|
package/src/web/server.js
CHANGED
|
@@ -22,7 +22,7 @@ import { createApiDispatch } from './routes/api.js';
|
|
|
22
22
|
import { ensureHome, loadConfig, saveConfig, mingdaoHome } from '../config.js';
|
|
23
23
|
import { setStoredKey, removeStoredKey, getStoredKey, maskKey } from '../credentials.js';
|
|
24
24
|
import { availableModels, fetchProviderModels, providerHasKey } from '../model-discovery.js';
|
|
25
|
-
import { createProvider, resolveProviderConfig, helperProvider } from '../providers/index.js';
|
|
25
|
+
import { createProvider, resolveProviderConfig, helperProvider, resolveVisionSupport } from '../providers/index.js';
|
|
26
26
|
import { MODELS, modelPreset, PROVIDERS } from '../models.js';
|
|
27
27
|
import { routeTask, routingConfig } from '../routing.js';
|
|
28
28
|
import { buildUserContent } from './attachments.js';
|
|
@@ -393,7 +393,11 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
393
393
|
}, 5000);
|
|
394
394
|
const userMessage = String(body.message ?? '').trim();
|
|
395
395
|
entry.message = (userMessage || '[附件]').slice(0, 40);
|
|
396
|
-
|
|
396
|
+
// v0.6.3(下游 Dify 工作流反馈):门控必须**也咨询自定义 Provider**。
|
|
397
|
+
// 原式只看内置预设与 customModels,于是自定义 Provider(Dify/网关适配器)永远被判不支持图片;
|
|
398
|
+
// 而为了让门控通过去写 customModels,又会把 provider 解析劫持到 openai-compatible 直连
|
|
399
|
+
// (见 providers/index.js 的 isEndpointDeclaration 注释)。现在统一走 resolveVisionSupport。
|
|
400
|
+
const visionSupported = await resolveVisionSupport(cfg, modelName);
|
|
397
401
|
const built = buildUserContent(userMessage, body.attachments, visionSupported);
|
|
398
402
|
if (built.error) {
|
|
399
403
|
entry.status = 'failed';
|
|
@@ -479,7 +483,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
479
483
|
const sessionWsDir = getSessionWorkspace(sessionName);
|
|
480
484
|
const taskDir = sessionWsDir || workingDir;
|
|
481
485
|
if (!sessionWsDir) {
|
|
482
|
-
setSessionWorkspace(sessionName, taskDir, currentWorkspace(workingDir)?.name || null);
|
|
486
|
+
await setSessionWorkspace(sessionName, taskDir, currentWorkspace(workingDir)?.name || null);
|
|
483
487
|
}
|
|
484
488
|
// v0.3.0 P0-3:项目记忆会话内快照——本会话全程用同一份快照(前缀稳定),
|
|
485
489
|
// 每轮结束提取的新条目只写文件、不回灌当前会话。
|
|
@@ -670,7 +674,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
670
674
|
const oldName = path.basename(session.file);
|
|
671
675
|
const renamed = renameSessionFile(fs, path, home, session, title);
|
|
672
676
|
if (renamed) {
|
|
673
|
-
moveSessionWorkspace(oldName, path.basename(renamed)); // 会话改名 → 工作空间映射跟随
|
|
677
|
+
await moveSessionWorkspace(oldName, path.basename(renamed)); // 会话改名 → 工作空间映射跟随
|
|
674
678
|
claimSessionKey(session.file); // 忙锁键同步迁移,否则新文件名发起的回合不会被判「忙」
|
|
675
679
|
}
|
|
676
680
|
}
|
package/src/workspace.js
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
// v0.6.2(自评 P2-7 阻塞面):本模块**全部**加锁函数已迁到异步版 `withFileLock`。
|
|
2
|
+
//
|
|
3
|
+
// 为什么先迁这一组:它正好在 WebUI 的请求路径上(开会话设目录、切换工作空间、会话改名),
|
|
4
|
+
// 而同步锁等待期间 `Atomics.wait` 会让**整个事件循环停摆**——实测持锁方存活 2.6 秒时,
|
|
5
|
+
// 一个 100ms 的定时器在锁返回前根本没触发,表现为所有并发会话/权限确认/SSE 流一起卡住。
|
|
6
|
+
// 且这组函数的 11 个调用点**全部**已在 async 处理函数里(web 路由 / 命令处理 / 会话改名),
|
|
7
|
+
// 迁移只是加 `await`,不产生 async 传染。
|
|
8
|
+
//
|
|
9
|
+
// 仍用同步版的调用点(tasks 2 处 / schedule 12 处 / cachestats 1 处 / sync 1 处)见
|
|
10
|
+
// docs/AUDIT-v0.6.1-第三方报告登记.md §3.26 的说明:它们要么在纯同步读-改-写链里
|
|
11
|
+
// (强行异步化会传染 24 个调用点,回归风险大于收益),要么在调度守护进程内部
|
|
12
|
+
// (阻塞只会推迟定时任务,不会冻结用户请求)。
|
|
13
|
+
//
|
|
1
14
|
// 工作空间:项目目录注册表(参考 WorkBuddy 的项目组织思路)——
|
|
2
15
|
// 为常做的项目登记名称与目录,一键回到对应项目,配置与记忆随目录(AGENTS.md / .mingdao/skills 等)自然跟随。
|
|
3
16
|
// 注册表:<mingdao-home>/workspaces.json → { "<名称>": { dir, createdAt, lastUsed } }
|
|
@@ -6,7 +19,7 @@
|
|
|
6
19
|
import fs from 'node:fs';
|
|
7
20
|
import path from 'node:path';
|
|
8
21
|
import { mingdaoHome, ensureHome } from './config.js';
|
|
9
|
-
import { atomicWriteFileSync,
|
|
22
|
+
import { atomicWriteFileSync, withFileLock } from './atomic-write.js';
|
|
10
23
|
|
|
11
24
|
export function workspacesFile() {
|
|
12
25
|
return path.join(mingdaoHome(), 'workspaces.json');
|
|
@@ -40,7 +53,7 @@ export function saveWorkspaces(/** @type {any} */ ws) {
|
|
|
40
53
|
}
|
|
41
54
|
}
|
|
42
55
|
|
|
43
|
-
export function addWorkspace(/** @type {any} */ name, /** @type {any} */ dir) {
|
|
56
|
+
export async function addWorkspace(/** @type {any} */ name, /** @type {any} */ dir) {
|
|
44
57
|
const key = String(name).trim();
|
|
45
58
|
if (!key) return { error: '名称不能为空' };
|
|
46
59
|
if (/[\\/]/.test(key)) return { error: '名称不能包含路径分隔符' };
|
|
@@ -48,7 +61,7 @@ export function addWorkspace(/** @type {any} */ name, /** @type {any} */ dir) {
|
|
|
48
61
|
if (!fs.existsSync(target)) return { error: `目录不存在:${target}` };
|
|
49
62
|
// v0.4.7(T19):读-改-写必须在跨进程锁内——WebUI 每次建会话都会 touch 工作空间,
|
|
50
63
|
// 与 CLI 的 add/remove 并发时未加锁会丢更新(注册表少一条工作空间)。
|
|
51
|
-
return
|
|
64
|
+
return withFileLock(workspacesFile() + '.lock', () => {
|
|
52
65
|
const ws = loadWorkspaces();
|
|
53
66
|
ws[key] = { dir: target, createdAt: ws[key]?.createdAt || Date.now(), lastUsed: Date.now() };
|
|
54
67
|
const saved = saveWorkspaces(ws);
|
|
@@ -57,8 +70,8 @@ export function addWorkspace(/** @type {any} */ name, /** @type {any} */ dir) {
|
|
|
57
70
|
});
|
|
58
71
|
}
|
|
59
72
|
|
|
60
|
-
export function removeWorkspace(/** @type {any} */ name) {
|
|
61
|
-
return
|
|
73
|
+
export async function removeWorkspace(/** @type {any} */ name) {
|
|
74
|
+
return withFileLock(workspacesFile() + '.lock', () => {
|
|
62
75
|
const ws = loadWorkspaces();
|
|
63
76
|
if (!ws[name]) return false;
|
|
64
77
|
delete ws[name];
|
|
@@ -70,12 +83,12 @@ export function removeWorkspace(/** @type {any} */ name) {
|
|
|
70
83
|
});
|
|
71
84
|
}
|
|
72
85
|
|
|
73
|
-
export function renameWorkspace(/** @type {any} */ name, /** @type {any} */ newName) {
|
|
86
|
+
export async function renameWorkspace(/** @type {any} */ name, /** @type {any} */ newName) {
|
|
74
87
|
const key = String(newName).trim();
|
|
75
88
|
if (!key) return { error: '新名称不能为空' };
|
|
76
89
|
if (/[\\/]/.test(key)) return { error: '名称不能包含路径分隔符' };
|
|
77
90
|
if (key === name) return { name: key }; // 原样改名:无操作,避免自删条目
|
|
78
|
-
return
|
|
91
|
+
return withFileLock(workspacesFile() + '.lock', () => {
|
|
79
92
|
const ws = loadWorkspaces();
|
|
80
93
|
if (!ws[name]) return { error: `工作空间 ${name} 不存在` };
|
|
81
94
|
if (ws[key]) return { error: `名称 ${key} 已存在` };
|
|
@@ -88,7 +101,7 @@ export function renameWorkspace(/** @type {any} */ name, /** @type {any} */ newN
|
|
|
88
101
|
}
|
|
89
102
|
|
|
90
103
|
// 修改目录:登记同名即可覆盖目录
|
|
91
|
-
export function setWorkspaceDir(/** @type {any} */ name, /** @type {any} */ dir) {
|
|
104
|
+
export async function setWorkspaceDir(/** @type {any} */ name, /** @type {any} */ dir) {
|
|
92
105
|
return addWorkspace(name, dir);
|
|
93
106
|
}
|
|
94
107
|
|
|
@@ -96,9 +109,9 @@ export function workspacePath(/** @type {any} */ name) {
|
|
|
96
109
|
return loadWorkspaces()[name]?.dir || null;
|
|
97
110
|
}
|
|
98
111
|
|
|
99
|
-
export function touchWorkspace(/** @type {any} */ name) {
|
|
112
|
+
export async function touchWorkspace(/** @type {any} */ name) {
|
|
100
113
|
// v0.4.7(T19):同上,加锁防并发丢更新
|
|
101
|
-
return
|
|
114
|
+
return withFileLock(workspacesFile() + '.lock', () => {
|
|
102
115
|
const ws = loadWorkspaces();
|
|
103
116
|
if (!ws[name]) return false;
|
|
104
117
|
ws[name].lastUsed = Date.now();
|
|
@@ -180,17 +193,17 @@ export function getSessionWorkspace(/** @type {any} */ sessionName) {
|
|
|
180
193
|
return loadSessionWorkspaces()[sessionName]?.dir || null;
|
|
181
194
|
}
|
|
182
195
|
|
|
183
|
-
export function setSessionWorkspace(/** @type {any} */ sessionName, /** @type {any} */ dir, wsName = null) {
|
|
196
|
+
export async function setSessionWorkspace(/** @type {any} */ sessionName, /** @type {any} */ dir, wsName = null) {
|
|
184
197
|
// v0.4.7(T19):多标签页/多任务并行时会话级映射同样会被并发改写
|
|
185
|
-
return
|
|
198
|
+
return withFileLock(sessionWorkspacesFile() + '.lock', () => {
|
|
186
199
|
const map = loadSessionWorkspaces();
|
|
187
200
|
map[sessionName] = { dir: path.resolve(dir), name: wsName || workspaceForDir(dir)?.name || null, at: Date.now() };
|
|
188
201
|
saveSessionWorkspaces(map);
|
|
189
202
|
});
|
|
190
203
|
}
|
|
191
204
|
|
|
192
|
-
export function removeSessionWorkspace(/** @type {any} */ sessionName) {
|
|
193
|
-
return
|
|
205
|
+
export async function removeSessionWorkspace(/** @type {any} */ sessionName) {
|
|
206
|
+
return withFileLock(sessionWorkspacesFile() + '.lock', () => {
|
|
194
207
|
const map = loadSessionWorkspaces();
|
|
195
208
|
if (!map[sessionName]) return false;
|
|
196
209
|
delete map[sessionName];
|
|
@@ -200,8 +213,8 @@ export function removeSessionWorkspace(/** @type {any} */ sessionName) {
|
|
|
200
213
|
}
|
|
201
214
|
|
|
202
215
|
// 会话改名时迁移映射(记录保留)
|
|
203
|
-
export function moveSessionWorkspace(/** @type {any} */ oldName, /** @type {any} */ newName) {
|
|
204
|
-
return
|
|
216
|
+
export async function moveSessionWorkspace(/** @type {any} */ oldName, /** @type {any} */ newName) {
|
|
217
|
+
return withFileLock(sessionWorkspacesFile() + '.lock', () => {
|
|
205
218
|
const map = loadSessionWorkspaces();
|
|
206
219
|
if (!map[oldName]) return false;
|
|
207
220
|
map[newName] = map[oldName];
|