dsh-engineering-ui 0.0.0-stage → 1.5.1

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.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +772 -3
  3. package/engineering/plugins/dsh-engineering-ui/cordis.patch.yml +5 -0
  4. package/engineering/plugins/dsh-engineering-ui/icon.svg +14 -0
  5. package/engineering/plugins/dsh-engineering-ui/lib/client.js +8257 -0
  6. package/engineering/plugins/dsh-engineering-ui/lib/defense-sign.js +1552 -0
  7. package/engineering/plugins/dsh-engineering-ui/lib/index.js +5634 -0
  8. package/engineering/plugins/dsh-engineering-ui/lib/job-wait-guard.js +236 -0
  9. package/engineering/plugins/dsh-engineering-ui/lib/mem-guard.js +331 -0
  10. package/engineering/plugins/dsh-engineering-ui/lib/webots.js +114 -0
  11. package/engineering/plugins/dsh-engineering-ui/locale/en.json +6 -0
  12. package/engineering/plugins/dsh-engineering-ui/locale/zh.json +6 -0
  13. package/engineering/plugins/dsh-engineering-ui/preset-engineering.patch.yml +601 -0
  14. package/engineering/skills/assembly-orchestration/SKILL.md +651 -0
  15. package/engineering/skills/cad-workflow/SKILL.md +132 -0
  16. package/engineering/skills/mode-selection/SKILL.md +660 -0
  17. package/engineering/skills/physics-in-loop/SKILL.md +238 -0
  18. package/engineering/skills/physics-in-loop/references/feadocs.md +142 -0
  19. package/engineering/skills/solidworks-bridge/SKILL.md +445 -0
  20. package/engineering/skills/sw-design/SKILL.md +112 -0
  21. package/engineering/skills/sw-design/references/autocad-workflow.md +81 -0
  22. package/engineering/skills/sw-design/references/gbt-drafting.md +71 -0
  23. package/engineering/skills/sw-to-cad/SKILL.md +121 -0
  24. package/engineering/skills/webots-simulation/SKILL.md +58 -0
  25. package/engineering/tools/LICENSE.Auto-SolidWorks +21 -0
  26. package/engineering/tools/_plugin_paths.py +93 -0
  27. package/engineering/tools/_root.py +330 -0
  28. package/engineering/tools/_store.py +446 -0
  29. package/engineering/tools/ac_bridge.py +459 -0
  30. package/engineering/tools/ac_rules.json +21 -0
  31. package/engineering/tools/ac_validate.py +699 -0
  32. package/engineering/tools/ask_user.py +887 -0
  33. package/engineering/tools/build_arm1.py +46 -0
  34. package/engineering/tools/build_assembly.py +94 -0
  35. package/engineering/tools/build_base.py +57 -0
  36. package/engineering/tools/build_housing.py +70 -0
  37. package/engineering/tools/build_structural.py +43 -0
  38. package/engineering/tools/build_transmission.py +69 -0
  39. package/engineering/tools/cadx.lsp +128 -0
  40. package/engineering/tools/choice_contract.py +61 -0
  41. package/engineering/tools/conn_state.py +1671 -0
  42. package/engineering/tools/defense_gate.py +3374 -0
  43. package/engineering/tools/drawing_input.py +314 -0
  44. package/engineering/tools/env_check.py +467 -0
  45. package/engineering/tools/examples/build_axis.py +142 -0
  46. package/engineering/tools/examples/build_cup.py +56 -0
  47. package/engineering/tools/examples/build_plate.py +58 -0
  48. package/engineering/tools/examples/build_sphere.py +30 -0
  49. package/engineering/tools/examples/build_transition_duct.py +381 -0
  50. package/engineering/tools/gate_integrity.py +401 -0
  51. package/engineering/tools/mode2_flow.py +3589 -0
  52. package/engineering/tools/mode_checker.py +77 -0
  53. package/engineering/tools/mode_gate.py +8013 -0
  54. package/engineering/tools/part_registry.py +4266 -0
  55. package/engineering/tools/physics/__init__.py +50 -0
  56. package/engineering/tools/physics/_compat.py +12 -0
  57. package/engineering/tools/physics/design_state.py +142 -0
  58. package/engineering/tools/physics/domain_validator.py +751 -0
  59. package/engineering/tools/physics/fatigue.py +489 -0
  60. package/engineering/tools/physics/fea_solver.py +1163 -0
  61. package/engineering/tools/physics/feapy_solver.py +314 -0
  62. package/engineering/tools/physics/gb_materials.json +1228 -0
  63. package/engineering/tools/physics/geometry_gate.py +337 -0
  64. package/engineering/tools/physics/load_case.py +951 -0
  65. package/engineering/tools/physics/material_db.py +874 -0
  66. package/engineering/tools/physics/mesh_adapter.py +321 -0
  67. package/engineering/tools/physics/refine_rules.py +272 -0
  68. package/engineering/tools/physics/rules/gb_base_rules.json +377 -0
  69. package/engineering/tools/physics/rules/gb_drafting_rules.json +57 -0
  70. package/engineering/tools/physics/rules/gb_electrical_rules.json +25 -0
  71. package/engineering/tools/physics/rules/gb_fasteners_rules.json +73 -0
  72. package/engineering/tools/physics/rules/gb_housing_rules.json +41 -0
  73. package/engineering/tools/physics/rules/gb_manufacturing_rules.json +25 -0
  74. package/engineering/tools/physics/rules/gb_material_verify_rules.json +72 -0
  75. package/engineering/tools/physics/rules/gb_materials_rules.json +377 -0
  76. package/engineering/tools/physics/rules/gb_safety_rules.json +89 -0
  77. package/engineering/tools/physics/rules/gb_springs_rules.json +41 -0
  78. package/engineering/tools/physics/rules/gb_standards_registry.json +1646 -0
  79. package/engineering/tools/physics/rules/gb_structural_rules.json +201 -0
  80. package/engineering/tools/physics/rules/gb_surface_rules.json +25 -0
  81. package/engineering/tools/physics/rules/gb_transmission_rules.json +233 -0
  82. package/engineering/tools/physics/rules/housing_rules.json +48 -0
  83. package/engineering/tools/physics/rules/manifest.json +54 -0
  84. package/engineering/tools/physics/rules/mold_rules.json +48 -0
  85. package/engineering/tools/physics/rules/structural_rules.json +56 -0
  86. package/engineering/tools/physics/rules/transmission_rules.json +48 -0
  87. package/engineering/tools/physics/simulation_report.py +829 -0
  88. package/engineering/tools/physics_bridge.py +2685 -0
  89. package/engineering/tools/profile-manifest.ps1 +72 -0
  90. package/engineering/tools/run_regression.py +109 -0
  91. package/engineering/tools/settings.py +419 -0
  92. package/engineering/tools/solidworks-modeling.md +519 -0
  93. package/engineering/tools/sw_bridge.py +8215 -0
  94. package/engineering/tools/sw_guard.py +1066 -0
  95. package/engineering/tools/sw_surface.py +87 -0
  96. package/engineering/tools/swapi.py +14766 -0
  97. package/engineering/tools/verify_sync.py +218 -0
  98. package/engineering/tools/visual_check.py +2589 -0
  99. package/engineering/tools/webots_env.py +121 -0
  100. package/engineering/tools/webots_mcp/__init__.py +3 -0
  101. package/engineering/tools/webots_mcp/__main__.py +3 -0
  102. package/engineering/tools/webots_mcp/assembly.py +391 -0
  103. package/engineering/tools/webots_mcp/bootstrap.py +55 -0
  104. package/engineering/tools/webots_mcp/controller_worker.py +124 -0
  105. package/engineering/tools/webots_mcp/evidence.py +288 -0
  106. package/engineering/tools/webots_mcp/fake_worker.py +69 -0
  107. package/engineering/tools/webots_mcp/fault_resources.py +121 -0
  108. package/engineering/tools/webots_mcp/host.py +45 -0
  109. package/engineering/tools/webots_mcp/ledger.py +73 -0
  110. package/engineering/tools/webots_mcp/owned_job.py +70 -0
  111. package/engineering/tools/webots_mcp/physical_gate.py +187 -0
  112. package/engineering/tools/webots_mcp/physics.py +152 -0
  113. package/engineering/tools/webots_mcp/pipeline.py +108 -0
  114. package/engineering/tools/webots_mcp/recipes.py +129 -0
  115. package/engineering/tools/webots_mcp/repair.py +132 -0
  116. package/engineering/tools/webots_mcp/repair_trial.py +375 -0
  117. package/engineering/tools/webots_mcp/runtime.py +311 -0
  118. package/engineering/tools/webots_mcp/server.py +217 -0
  119. package/engineering/tools/webots_mcp/sw_export.py +509 -0
  120. package/engineering/tools/webots_mcp/transport.py +156 -0
  121. package/engineering/tools/workflow_gate.py +6134 -0
  122. package/package.json +66 -6
@@ -0,0 +1,236 @@
1
+ /**
2
+ * job-wait-guard.js — 【卡死修复 · 宿主层】job_output 长等待的静默阻塞与整轮中断
3
+ * ==========================================================================
4
+ * 事故现场(会话 session-6019cd4b-1ade-4e1f-8850-93f5aec97960,2026-10-31)
5
+ * · 22:45:32 父会话调用 job_output { job_id, timeout_ms: 270000, wait: true }
6
+ * · 22:45:56 总计子代理调用 job_output { job_id, timeout_ms: 240000, wait: true }
7
+ * · 22:46:58 两者【同一毫秒】失败:
8
+ * Error: cannot get required service "jobs" in inactive context
9
+ * turn/end { kind: "interrupted" } ← 整轮结束
10
+ * · 用户只能强退再发「继续,刚刚卡死了,我只能强制退出了」
11
+ * · 22:49:03 / 22:49:34 重试(120000 / 330000)→ 22:50:42 再次一模一样地失败
12
+ *
13
+ * 根因(两层,缺一不可)
14
+ * ①【静默长阻塞】job_output 的 wait 一次性阻塞几分钟,期间 GUI 没有任何反馈;
15
+ * 用户看到的就是"DSH 死机",于是强制退出。这是用户可感知伤害的直接来源。
16
+ * ②【异常冒到 turn 级】等待期间宿主若停机 / 该服务 fiber 失活,cordis 在解析
17
+ * ctx.jobs 时抛 "cannot get required service ... in inactive context";
18
+ * 该异常穿过工具边界冒到 agent loop,最终被记成 turn/end{interrupted},
19
+ * 整轮对话直接作废。
20
+ *
21
+ * 本模块修的就是这两点,且【不依赖预设人设写没写对】:
22
+ * A. 把 job_output 的单次等待切成若干【短窗口】(默认 45s)。窗口到点后照常
23
+ * 返回 "仍在运行" 给模型 —— 模型拿到进度、GUI 保持响应、用户随时能打断。
24
+ * B. 任何异常(含上面的 inactive context)都转成**普通工具错误**(isError=true,
25
+ * 带可操作指引),绝不让框架异常终结整轮。
26
+ *
27
+ * 机制(官方契约,非 hack)
28
+ * @deepseek-ai/dsh-tool-call-timeout-policy 使用的就是同一层:
29
+ * ctx.on('tools/execute', async (exec, next) => ...)
30
+ * 这里只补"等待窗口 + 异常降级",不改变 job_output 的任何正常语义。
31
+ *
32
+ * 关闭开关(写错配置也绝不会误关;非法值一律回落默认)
33
+ * DSH_ENG_JOB_WAIT_GUARD=0 → 整个守卫关闭
34
+ * DSH_ENG_JOB_WAIT_MAX_MS=<ms> → 单次等待上限(默认 45000,最小 5000)
35
+ *
36
+ * ⚠️ 设计约束(每条都对应一次实测踩坑,勿随意简化)
37
+ * 1. 只处理 name === 'job_output' 且 wait:true 且请求超时超过窗口的调用,
38
+ * 其余工具一律原样透传。
39
+ * 2. 窗口未到点时必须原样返回 next() 的真实结果,不改变任何正常语义。
40
+ * 3. 窗口到点返回的是【正常成功结果】,不是 throw —— 绝不能用另一种方式
41
+ * 重新把对话打断。
42
+ * 4. 纯 JS 实现、零外部依赖:宿主层缺包会整块不加载,那样修复就白做了。
43
+ * 5. 守卫自身任何异常都要回落 next(),守卫坏了不能拖垮工具。
44
+ */
45
+
46
+ /** 单次等待窗口上限(毫秒)。45s 覆盖一次 SW 建模步骤,又远低于"用户以为死机"的阈值。 */
47
+ const DEFAULT_MAX_WAIT_MS = 45000;
48
+ /** 下限:低于 5s 的等待会退化成忙轮询。 */
49
+ const MIN_MAX_WAIT_MS = 5000;
50
+
51
+ /** 守卫是否启用(默认启用;显式 0/false/off/no 才关闭)。 */
52
+ function guardEnabled() {
53
+ try {
54
+ const raw = process.env.DSH_ENG_JOB_WAIT_GUARD;
55
+ if (raw === undefined || raw === null || String(raw).trim() === '') return true;
56
+ const v = String(raw).trim().toLowerCase();
57
+ return !(v === '0' || v === 'false' || v === 'off' || v === 'no');
58
+ } catch (e) {
59
+ return true;
60
+ }
61
+ }
62
+
63
+ /** 等待上限:环境变量可覆盖,非法值一律回落默认。 */
64
+ function resolveMaxWaitMs() {
65
+ try {
66
+ const raw = process.env.DSH_ENG_JOB_WAIT_MAX_MS;
67
+ if (raw !== undefined && raw !== null && String(raw).trim() !== '') {
68
+ const v = Number(String(raw).trim());
69
+ if (Number.isFinite(v) && v >= MIN_MAX_WAIT_MS) return Math.floor(v);
70
+ }
71
+ } catch (e) {}
72
+ return DEFAULT_MAX_WAIT_MS;
73
+ }
74
+
75
+ /**
76
+ * 是否为"宿主上下文 / 服务已失活"这一类框架层拒绝。
77
+ * 命中后必须降级为可读工具错误,绝不能再让它冒到 turn 级。
78
+ */
79
+ function isContextLostError(error) {
80
+ try {
81
+ const msg = String((error && error.message) || error || '');
82
+ return /inactive context/i.test(msg)
83
+ || /cannot get required service/i.test(msg);
84
+ } catch (e) {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ /** 上下文失活时给模型的、可操作的错误结果。 */
90
+ function contextLostResult(jobId) {
91
+ const message = 'job_output 等待失败:后台任务所属的宿主上下文已失效(DSH 可能被重启,或该服务已卸载)。'
92
+ + '这不代表任务本身失败。请改用磁盘 / 文件产物判断进度(例如 mode2_plan.json、room-board、reports/),'
93
+ + '或先用 job_list 确认该任务是否还在注册表中,再决定是否重新发起。';
94
+ return {
95
+ content: [{ type: 'text', text: 'Error: ' + message + (jobId ? '(job_id=' + jobId + ')' : '') }],
96
+ isError: true,
97
+ error: {
98
+ message: 'JOB_CONTEXT_LOST: ' + message,
99
+ info: { name: 'JobContextLostError', code: 'JOB_CONTEXT_LOST', jobId },
100
+ },
101
+ };
102
+ }
103
+
104
+ /** 等待窗口到点时给模型的"仍在运行"回执(正常成功返回,不是错误)。 */
105
+ function waitWindowResult(jobId, waitedMs, sawStatus) {
106
+ const status = sawStatus ? '(当前状态:' + sawStatus + ')' : '';
107
+ const text = '[等待窗口已到 ' + Math.round(waitedMs / 1000) + 's] 后台任务 ' + jobId
108
+ + ' 仍在运行' + status + '。\n'
109
+ + '这是【正常的中途返回】,不是失败:任务继续在后台跑。\n'
110
+ + '要继续等待请再调一次 job_output(wait:true);先做别的事就稍后再读;'
111
+ + '真的不需要了就 job_kill。建议同时用磁盘产物判断进度,别把整轮对话挂在等待上。';
112
+ return {
113
+ content: [{ type: 'text', text }],
114
+ isError: false,
115
+ meta: { jobWaitWindow: waitedMs },
116
+ };
117
+ }
118
+
119
+ /** 从一次 job_output 的结果里抽状态行,用于"仍在运行"回执。 */
120
+ function statusOf(result) {
121
+ try {
122
+ const blocks = (result && result.content) || [];
123
+ for (const b of blocks) {
124
+ if (!b || b.type !== 'text' || typeof b.text !== 'string') continue;
125
+ const m = b.text.match(/\[status:\s*([^\]]+)\]/);
126
+ if (m) return m[1].trim();
127
+ }
128
+ const job = result && result.value && result.value.job;
129
+ if (job && job.status) return String(job.status);
130
+ } catch (e) {}
131
+ return '';
132
+ }
133
+
134
+ /** 该次调用是否属于本守卫要处理的长等待。 */
135
+ function inspectsLongWait(exec, maxWaitMs) {
136
+ try {
137
+ if (!exec || exec.name !== 'job_output') return null;
138
+ const args = exec.arguments || {};
139
+ if (args.wait !== true) return null;
140
+ const requested = (typeof args.timeout_ms === 'number' && Number.isFinite(args.timeout_ms))
141
+ ? args.timeout_ms
142
+ : 30000; // 与 dsh-tool-jobs 的 waitTimeoutMs 默认一致
143
+ if (requested <= maxWaitMs) return null; // 本来就短,不必介入
144
+ return { jobId: String(args.job_id || ''), requested, effective: maxWaitMs };
145
+ } catch (e) {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * 装配守卫。返回 disposer。
152
+ * @param ctx 宿主插件上下文(需可监听 'tools/execute')
153
+ * @returns {() => void}
154
+ */
155
+ export function installJobWaitGuard(ctx) {
156
+ if (!guardEnabled() || !ctx || typeof ctx.on !== 'function') {
157
+ return () => {};
158
+ }
159
+ const maxWaitMs = resolveMaxWaitMs();
160
+
161
+ const listener = async (exec, next) => {
162
+ let plan = null;
163
+ try {
164
+ plan = inspectsLongWait(exec, maxWaitMs);
165
+ } catch (e) {
166
+ plan = null;
167
+ }
168
+
169
+ // ── 非长等待:原样透传;仅当是 job_output 且命中上下文失活时降级 ──
170
+ if (!plan) {
171
+ try {
172
+ return await next();
173
+ } catch (error) {
174
+ if (exec && exec.name === 'job_output' && isContextLostError(error)) {
175
+ return contextLostResult(exec.arguments && exec.arguments.job_id);
176
+ }
177
+ throw error;
178
+ }
179
+ }
180
+
181
+ // ── 长等待:用【窗口竞速】实现,绝不改写 exec.arguments ─────────────
182
+ // ⚠️ 为什么不能改写参数(实测契约,别想当然):
183
+ // dsh-tools 在 createExecution 里就做了
184
+ // arguments: deepFreeze(snapshotJsonValue(exec.arguments))
185
+ // 所以 tools/execute 包装层拿到的 exec.arguments 是【深冻结】的,
186
+ // 任何赋值都 throws(严格模式)。早先"把 timeout_ms 改小"的写法在这里
187
+ // 会被静默跳过 —— 看起来装上了守卫,实际一次都没生效。
188
+ // 因此这里改为:保留原始参数不动,自己给 next() 设一个截止时间;
189
+ // 到点后该工具调用立即返回(后台 job 继续跑,语义与 job_output 一致)。
190
+ const startedAt = Date.now();
191
+ let timer = null;
192
+ let finished = false;
193
+ const windowExpired = new Promise((resolve) => {
194
+ timer = setTimeout(() => {
195
+ if (finished) return;
196
+ resolve('window');
197
+ }, maxWaitMs);
198
+ // 让定时器不阻塞进程退出(宿主关闭时立刻放行,不留悬挂 handle)
199
+ try { if (timer && typeof timer.unref === 'function') timer.unref(); } catch (e) {}
200
+ });
201
+
202
+ try {
203
+ const raced = await Promise.race([
204
+ Promise.resolve(next()).then((result) => ({ kind: 'done', result })),
205
+ windowExpired.then(() => ({ kind: 'window' })),
206
+ ]);
207
+ finished = true;
208
+ if (timer) { try { clearTimeout(timer); } catch (e) {} }
209
+ if (raced.kind === 'window') {
210
+ // 窗口到点:返回正常回执,任务仍在后台运行(不是失败、不打断对话)
211
+ return waitWindowResult(plan.jobId, Date.now() - startedAt, '');
212
+ }
213
+ const result = raced.result;
214
+ const st = statusOf(result);
215
+ // 任务已结束(completed / killed / failed)→ 真实结果原样返回
216
+ if (st && !/running|stopping/i.test(st)) return result;
217
+ return waitWindowResult(plan.jobId, Date.now() - startedAt, st);
218
+ } catch (error) {
219
+ finished = true;
220
+ if (timer) { try { clearTimeout(timer); } catch (e) {} }
221
+ if (isContextLostError(error)) return contextLostResult(plan.jobId);
222
+ throw error;
223
+ }
224
+ };
225
+
226
+ const off = ctx.on('tools/execute', listener);
227
+ return () => {
228
+ try { if (typeof off === 'function') off(); } catch (e) {}
229
+ };
230
+ }
231
+
232
+ export const JOB_WAIT_GUARD_DEFAULTS = Object.freeze({
233
+ enabled: true,
234
+ maxWaitMs: DEFAULT_MAX_WAIT_MS,
235
+ minWaitMs: MIN_MAX_WAIT_MS,
236
+ });
@@ -0,0 +1,331 @@
1
+ /**
2
+ * mem-guard.js — 【内存守卫】子代理并发限流 + 只回收工作集(不杀进程、不打断流程)
3
+ * ==========================================================================
4
+ * 为什么要有这个模块(两件事,一个来源)
5
+ * ① 多屋并行时主对话会一口气 subagent_fork 出 8~12 个小屋:每个小屋是
6
+ * 一个独立 Agent + 独立上下文档,同时跑 SolidWorks 时把测试机内存打爆;
7
+ * ② 用户要求「最多同时 3 个子代理」,且「清理系统运行内存但不要影响流程」。
8
+ *
9
+ * 结论(也是本模块的设计红线)
10
+ * · 唯一安全的「清理内存」手段是 EmptyWorkingSet:它把进程工作集里的干净页
11
+ * 换出到 standby / pagefile,进程继续存活,下次访问时由缺页中断换回来 ——
12
+ * **不杀进程、不断会话、不丢上下文**。
13
+ * · 绝不使用杀 SLDWORKS / 杀 node / 清 standby list 这类会破坏流程的手段。
14
+ * 杀 SW 已由 sw_guard 负责(只在 CPU/内存 > 85% 的极端情况触发),
15
+ * 本模块只做低风险回收。
16
+ * · 任何一步失败都静默降级(返回 ok:false + 原因),绝不抛、绝不影响主流程。
17
+ *
18
+ * 两条能力
19
+ * A. 并发闸门:evaluateConcurrency() 是纯函数,输入 subagents.listDescendants
20
+ * 的结果,输出是否允许再 fork。真正的拦截点在 index.js 的 tools/pre-execute。
21
+ * B. 工作集回收:reclaimWorkingSets() 只对本进程 + 会话相关进程调用
22
+ * EmptyWorkingSet;memorySnapshot() 用 kernel32.GlobalMemoryStatusEx 读真实
23
+ * 可用内存,供阈值判定与报告。
24
+ *
25
+ * 环境开关(写错一律回落默认,绝不因配置把功能关死)
26
+ * DSH_ENG_MEM_GUARD=0 → 关闭整个内存守卫(并发闸门 + 回收都停)
27
+ * DSH_ENG_MAX_SUBAGENTS=3 → 并发子代理上限(默认 3,范围 1..12)
28
+ * DSH_ENG_MEM_RECLAIM_PCT=85 → 内存占用超过该百分比才做工作集回收
29
+ * DSH_ENG_MEM_RECLAIM_MS=90000 → 两次回收的最小间隔
30
+ *
31
+ * 依赖:只用 Node 内置模块(child_process)。EmptyWorkingSet 通过 PowerShell
32
+ * 一行 Add-Type 调 psapi.dll —— 不引入任何第三方包,宿主缺包也不会整块挂掉。
33
+ */
34
+
35
+ import { spawnSync } from 'node:child_process';
36
+
37
+ /** 默认并发上限(用户明确要求:最多同时开 3 个子代理)。 */
38
+ export const DEFAULT_MAX_SUBAGENTS = 3;
39
+ /** 上限的硬边界:再大就等于没有闸门。 */
40
+ const MAX_SUBAGENTS_CAP = 12;
41
+ /** 默认内存占用阈值(百分比)。 */
42
+ const DEFAULT_RECLAIM_PCT = 85;
43
+ /** 两次回收的最小间隔。 */
44
+ const DEFAULT_RECLAIM_MS = 90000;
45
+
46
+ /** 守卫是否启用(默认启用;显式 0/false/off/no 才关闭)。 */
47
+ export function memGuardEnabled() {
48
+ try {
49
+ const raw = process.env.DSH_ENG_MEM_GUARD;
50
+ if (raw === undefined || raw === null || String(raw).trim() === '') return true;
51
+ const v = String(raw).trim().toLowerCase();
52
+ return !(v === '0' || v === 'false' || v === 'off' || v === 'no');
53
+ } catch (e) {
54
+ return true;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * 运行期覆盖(由宿主从 tools/mem_guard.json 读出来后注入)。
60
+ * 优先级:运行期覆盖 > 环境变量 > 内置默认。
61
+ * 之所以留这一层:设置页改完要【立刻生效】,而环境变量在宿主进程里改不动。
62
+ */
63
+ let RUNTIME_OVERRIDES = {};
64
+
65
+ /** 注入运行期覆盖(宿主在装配时调用;非法值一律忽略)。 */
66
+ export function setMemGuardOverrides(obj) {
67
+ RUNTIME_OVERRIDES = (obj && typeof obj === 'object') ? { ...obj } : {};
68
+ }
69
+
70
+ /** 读当前运行期覆盖(只读副本,供状态路由回显)。 */
71
+ export function memGuardOverrides() {
72
+ return { ...RUNTIME_OVERRIDES };
73
+ }
74
+
75
+ /** 取一个数:运行期覆盖 → 环境变量 → 默认。 */
76
+ function pickInt(key, envName, fallback, min, max) {
77
+ const over = RUNTIME_OVERRIDES ? RUNTIME_OVERRIDES[key] : undefined;
78
+ if (over !== undefined && over !== null && String(over).trim() !== '') {
79
+ const n = Number(String(over).trim());
80
+ if (Number.isFinite(n)) {
81
+ const v = Math.floor(n);
82
+ if (v >= min) return Math.min(max, v);
83
+ }
84
+ }
85
+ return positiveInt(process.env[envName], fallback, min, max);
86
+ }
87
+
88
+ function positiveInt(raw, fallback, min, max) {
89
+ try {
90
+ if (raw === undefined || raw === null || String(raw).trim() === '') return fallback;
91
+ const v = Number(String(raw).trim());
92
+ if (!Number.isFinite(v)) return fallback;
93
+ const n = Math.floor(v);
94
+ if (n < min) return fallback;
95
+ return Math.min(max, n);
96
+ } catch (e) {
97
+ return fallback;
98
+ }
99
+ }
100
+
101
+ /** 并发子代理上限(默认 3)。 */
102
+ export function maxActiveSubagents() {
103
+ return pickInt('max_subagents', 'DSH_ENG_MAX_SUBAGENTS', DEFAULT_MAX_SUBAGENTS, 1, MAX_SUBAGENTS_CAP);
104
+ }
105
+
106
+ /** 触发工作集回收的内存占用百分比。 */
107
+ export function reclaimThresholdPct() {
108
+ return pickInt('reclaim_threshold_pct', 'DSH_ENG_MEM_RECLAIM_PCT', DEFAULT_RECLAIM_PCT, 50, 99);
109
+ }
110
+
111
+ /** 两次回收的最小间隔(毫秒)。 */
112
+ export function reclaimIntervalMs() {
113
+ return pickInt('reclaim_interval_ms', 'DSH_ENG_MEM_RECLAIM_MS', DEFAULT_RECLAIM_MS, 5000, 3600000);
114
+ }
115
+
116
+ /**
117
+ * 该 root 当前「占着并发名额」的子代理 id 列表。
118
+ *
119
+ * ── 为什么统计孙代(实测坑)────────────────────────────────────────
120
+ * DSH 的子代理名额由每个 root 的 ActivationPool 统一计数,且只有子代理真正
121
+ * 结束(自然收敛或被 dispose)才释放。主对话 fork 出来、已经跑完但仍驻留的
122
+ * 小屋照样占名额 —— 只数「自己的直接子代理」会低估,于是闸门看起来放行了,
123
+ * 实际 fork 仍会被宿主拒绝(ACTIVATION_LIMIT_REACHED)。
124
+ *
125
+ * @param rows subagents.listDescendants(rootId) 的返回值
126
+ * @param rootId root 会话 id
127
+ */
128
+ export function countOccupiedChildren(rows, rootId) {
129
+ const out = [];
130
+ const list = Array.isArray(rows) ? rows : [];
131
+ for (const row of list) {
132
+ if (!row || row.kind !== 'child' || !row.id) continue;
133
+ if (String(row.parentId || '') === String(rootId || '')) { out.push(String(row.id)); continue; }
134
+ if (row.hasChildren === true) out.push(String(row.id));
135
+ }
136
+ return { ids: out, hasChildren: list.some((r) => r && r.kind === 'child') };
137
+ }
138
+
139
+ /**
140
+ * 并发闸门判定(纯函数,便于单测)。
141
+ *
142
+ * @param rows listDescendants 结果
143
+ * @param rootId 当前 root 会话 id
144
+ * @param _agentId 想 fork 的那个 agent id(保留参数,便于日志)
145
+ * @param max 上限
146
+ */
147
+ export function evaluateConcurrency(rows, rootId, _agentId, max) {
148
+ const limit = Number.isFinite(max) && max > 0 ? max : DEFAULT_MAX_SUBAGENTS;
149
+ const active = countOccupiedChildren(rows, rootId).ids.length;
150
+ if (active < limit) return { allowed: true, active, max: limit };
151
+ const message = '子代理并发已达上限(当前 ' + active + ' / 上限 ' + limit + '),本次 subagent_fork 已被拒绝。'
152
+ + '\n【为什么】每个子代理都常驻占用内存与一个 DSH 子代理名额;同批开太多会把内存打爆,整个工程模式一起卡死。'
153
+ + '上限可用 DSH_ENG_MAX_SUBAGENTS 调整(默认 3)。'
154
+ + '\n【现在怎么做】先等一个已在跑的小屋结束(或对它 room-fail / interrupt_agent 收尾),'
155
+ + '再用 mode_gate.py room-board 确认名额释放,然后重新 fork。'
156
+ + '\n【不要】把多的零件塞给同一个房间硬编 —— 那会让该房间的报告与门禁口径对不上。';
157
+ return { allowed: false, active, max: limit, reason: 'SUBAGENT_LIMIT_REACHED', message };
158
+ }
159
+
160
+ /** 会话相关进程:自己 + DSH 宿主 + SolidWorks。 */
161
+ export function targetProcessNames() {
162
+ return ['node.exe', 'SLDWORKS.exe', 'DeepSeek Harness.exe'];
163
+ }
164
+
165
+ /**
166
+ * 生成读内存 + 回收工作集的 PowerShell 脚本。
167
+ *
168
+ * 只用 psapi.EmptyWorkingSet:把工作集里的干净页移出(standby),进程继续运行;
169
+ * 不做 SetProcessWorkingSetSize(-1,-1) 的硬修剪(x64 上参数会被截断,且会强制
170
+ * 把整个工作集刷到 pagefile,反而拖慢下一次访问)。
171
+ *
172
+ * @param pids 目标进程 id 列表(空数组 = 只读内存,不回收)
173
+ */
174
+ export function buildWorkingSetScript(pids) {
175
+ const list = Array.isArray(pids)
176
+ ? pids.map((n) => Number(n)).filter((n) => Number.isFinite(n) && n > 0)
177
+ : [];
178
+ const arr = list.length ? list.join(',') : '';
179
+ return [
180
+ '$ErrorActionPreference = "SilentlyContinue"',
181
+ 'Add-Type -Namespace DshMem -Name Native -MemberDefinition @"',
182
+ '[DllImport("kernel32.dll", SetLastError=true)] public static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX lpBuffer);',
183
+ '[DllImport("psapi.dll", SetLastError=true)] public static extern bool EmptyWorkingSet(IntPtr hProcess);',
184
+ '[DllImport("kernel32.dll", SetLastError=true)] public static extern IntPtr OpenProcess(uint access, bool inherit, uint pid);',
185
+ '[DllImport("kernel32.dll", SetLastError=true)] public static extern bool CloseHandle(IntPtr h);',
186
+ '[StructLayout(LayoutKind.Sequential)] public struct MEMORYSTATUSEX {',
187
+ ' public uint dwLength; public uint dwMemoryLoad;',
188
+ ' public ulong ullTotalPhys; public ulong ullAvailPhys;',
189
+ ' public ulong ullTotalPageFile; public ulong ullAvailPageFile;',
190
+ ' public ulong ullTotalVirtual; public ulong ullAvailVirtual;',
191
+ ' public ulong ullAvailExtendedVirtual;',
192
+ '}',
193
+ '"@',
194
+ '$m = New-Object DshMem.Native+MEMORYSTATUSEX',
195
+ '$m.dwLength = [System.Runtime.InteropServices.Marshal]::SizeOf($m)',
196
+ '[void][DshMem.Native]::GlobalMemoryStatusEx([ref]$m)',
197
+ '$before = [pscustomobject]@{',
198
+ ' load = [int]$m.dwMemoryLoad',
199
+ ' total_mb = [math]::Round($m.ullTotalPhys / 1MB)',
200
+ ' free_mb = [math]::Round($m.ullAvailPhys / 1MB)',
201
+ '}',
202
+ '$ok = 0',
203
+ '$failed = 0',
204
+ arr ? ('$targets = @(' + arr + ')') : '$targets = @()',
205
+ 'foreach ($p in $targets) {',
206
+ ' $h = [DshMem.Native]::OpenProcess(0x1500, $false, [uint32]$p)',
207
+ ' if ($h -eq [IntPtr]::Zero) { $failed++; continue }',
208
+ ' try { if ([DshMem.Native]::EmptyWorkingSet($h)) { $ok++ } else { $failed++ } }',
209
+ ' finally { [void][DshMem.Native]::CloseHandle($h) }',
210
+ '}',
211
+ '[void][DshMem.Native]::GlobalMemoryStatusEx([ref]$m)',
212
+ '$after = [pscustomobject]@{',
213
+ ' load = [int]$m.dwMemoryLoad',
214
+ ' total_mb = [math]::Round($m.ullTotalPhys / 1MB)',
215
+ ' free_mb = [math]::Round($m.ullAvailPhys / 1MB)',
216
+ '}',
217
+ '[pscustomobject]@{ before = $before; after = $after; reclaimed = $ok; failed = $failed } | ConvertTo-Json -Compress -Depth 4',
218
+ ].join('\n');
219
+ }
220
+
221
+ /** PowerShell 可能带警告行,取最后一段 JSON。 */
222
+ export function parseLastJson(text) {
223
+ const s = String(text || '').trim();
224
+ if (!s) return null;
225
+ try { return JSON.parse(s); } catch (e) { /* 继续 */ }
226
+ const i = s.indexOf('{');
227
+ const j = s.lastIndexOf('}');
228
+ if (i >= 0 && j > i) {
229
+ try { return JSON.parse(s.slice(i, j + 1)); } catch (e) { return null; }
230
+ }
231
+ return null;
232
+ }
233
+
234
+ /**
235
+ * 只读内存快照(不回收)。失败时返回 ok:false,绝不抛。
236
+ */
237
+ export function memorySnapshot() {
238
+ try {
239
+ const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', buildWorkingSetScript([])], {
240
+ encoding: 'utf8', windowsHide: true, timeout: 15000,
241
+ });
242
+ if (!r || typeof r.stdout !== 'string' || !r.stdout.trim()) {
243
+ return { ok: false, error: String((r && r.stderr) || 'powershell 无输出').slice(0, 400) };
244
+ }
245
+ const j = parseLastJson(r.stdout);
246
+ if (!j || !j.before) return { ok: false, error: '内存快照解析失败' };
247
+ return { ok: true, load: j.before.load, total_mb: j.before.total_mb, free_mb: j.before.free_mb };
248
+ } catch (e) {
249
+ return { ok: false, error: String((e && e.message) || e) };
250
+ }
251
+ }
252
+
253
+ /** 按进程名取 pid(tasklist /FO CSV 解析,失败返回空数组)。 */
254
+ export function listPidsByName(names) {
255
+ const wanted = new Set((Array.isArray(names) ? names : []).map((n) => String(n).toLowerCase()));
256
+ if (!wanted.size) return [];
257
+ try {
258
+ const r = spawnSync('tasklist', ['/FO', 'CSV', '/NH'], { encoding: 'utf8', windowsHide: true, timeout: 15000 });
259
+ const out = String((r && r.stdout) || '');
260
+ const pids = [];
261
+ for (const line of out.split(/\r?\n/)) {
262
+ if (!line.trim()) continue;
263
+ const cols = line.split('","').map((c) => c.replace(/^"|"$/g, ''));
264
+ if (cols.length < 2) continue;
265
+ if (!wanted.has(String(cols[0] || '').toLowerCase())) continue;
266
+ const pid = Number(cols[1]);
267
+ if (Number.isFinite(pid) && pid > 0) pids.push(pid);
268
+ }
269
+ return pids;
270
+ } catch (e) {
271
+ return [];
272
+ }
273
+ }
274
+
275
+ /**
276
+ * 回收工作集并返回前后对比。
277
+ *
278
+ * @param options.pids 显式指定的进程 id(不传则按进程名匹配)
279
+ * @param options.force 忽略阈值与间隔(仍受 memGuardEnabled 控制)
280
+ */
281
+ export function reclaimWorkingSets(options) {
282
+ const opts = options || {};
283
+ if (!memGuardEnabled()) return { ok: false, skipped: 'disabled' };
284
+ let pids = Array.isArray(opts.pids) ? opts.pids : null;
285
+ if (!pids || !pids.length) {
286
+ if (!opts.force) {
287
+ const snap = memorySnapshot();
288
+ if (!snap.ok) return { ok: false, error: snap.error };
289
+ if (typeof snap.load === 'number' && snap.load < reclaimThresholdPct()) {
290
+ return { ok: true, skipped: 'below-threshold', before: snap };
291
+ }
292
+ }
293
+ pids = listPidsByName(targetProcessNames());
294
+ }
295
+ if (!pids.length) return { ok: true, skipped: 'no-targets' };
296
+ try {
297
+ const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', buildWorkingSetScript(pids)], {
298
+ encoding: 'utf8', windowsHide: true, timeout: 30000,
299
+ });
300
+ const j = parseLastJson(r && r.stdout);
301
+ if (!j || !j.before) {
302
+ return { ok: false, error: String((r && r.stderr) || 'powershell 无输出').slice(0, 400) };
303
+ }
304
+ return {
305
+ ok: true, before: j.before, after: j.after,
306
+ reclaimed: Number(j.reclaimed) || 0, failed: Number(j.failed) || 0, pids,
307
+ };
308
+ } catch (e) {
309
+ return { ok: false, error: String((e && e.message) || e) };
310
+ }
311
+ }
312
+
313
+ /**
314
+ * 节流回收器:同一进程内两次回收至少间隔 reclaimIntervalMs()。
315
+ * 供「每轮结束 / 每次 fork 之后」的轻量调用,避免频繁刷工作集。
316
+ */
317
+ export function createReclaimer() {
318
+ let lastAt = 0;
319
+ let lastResult = null;
320
+ return function maybeReclaim(options) {
321
+ const opts = options || {};
322
+ const now = Date.now();
323
+ if (!opts.force && lastAt && (now - lastAt) < reclaimIntervalMs()) {
324
+ return { ok: true, skipped: 'throttled', last: lastResult };
325
+ }
326
+ const r = reclaimWorkingSets(opts);
327
+ lastAt = now;
328
+ lastResult = r;
329
+ return r;
330
+ };
331
+ }