dsh-remote-agent 0.4.3 → 0.6.0
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/CHANGELOG.md +69 -0
- package/README.en.md +49 -1
- package/README.md +40 -1
- package/SECURITY.md +24 -0
- package/lib/hub/node-registry.js +73 -0
- package/lib/hub/registry-ingest.js +18 -0
- package/lib/hub/tools.js +261 -4
- package/lib/node/fs-service.js +504 -0
- package/lib/node/index.js +44 -2
- package/lib/node/task-engine.js +51 -4
- package/lib/node/transport.js +2 -0
- package/lib/protocol.js +13 -0
- package/package.json +1 -1
- package/scripts/check-doc-drift.mjs +18 -3
- package/scripts/diag-inject.mjs +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,75 @@
|
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
+
## [0.6.0] - 2026-10-17
|
|
13
|
+
|
|
14
|
+
### 修复:单条慢命令锁死整台副机(提案 #26,实测事故防线)
|
|
15
|
+
|
|
16
|
+
实测事故:两条慢 exec 占满 `maxConcurrent=2` 后,后续任务**全部 queued 且一条
|
|
17
|
+
都不执行**;重启副机后重派同样命令再次锁死。根因是 `maxConcurrent=2` 与
|
|
18
|
+
exec 默认超时 1 小时的组合 —— 一条卡住的命令 = 整台副机一小时不可用。
|
|
19
|
+
|
|
20
|
+
**行为变更(默认值)**:
|
|
21
|
+
|
|
22
|
+
- **exec / agent 默认超时分开**:exec 10 分钟(绝大多数命令 1 分钟内结束,
|
|
23
|
+
10 分钟足够暴露"命令本身有问题")、agent 保持 1 小时。工具层与副机兜底
|
|
24
|
+
**同源常量**(`task-engine.js` 导出,`tools.js` 直接 import),显式
|
|
25
|
+
`timeoutMs` 仍然优先。
|
|
26
|
+
- **maxConcurrent 默认 2 → 4**:2 太脆 —— 两条慢任务就能锁死整台机器;
|
|
27
|
+
4 让"两条慢 + 两条快"正常共存。配置项保留,按内存调整。
|
|
28
|
+
|
|
29
|
+
**可见性:队列健康遥测(#26-3)**:
|
|
30
|
+
|
|
31
|
+
- 副机 `TaskEngine.queueStats()` 每 30s 随资源遥测帧搭车上报:
|
|
32
|
+
`{queued, running, maxConcurrent, oldestQueuedMs, starved}`。
|
|
33
|
+
`starved` = 有排队 + 并发占满 + 最久排队超 2 分钟。
|
|
34
|
+
- `remote_nodes` 透出 `queue` 字段;starved 节点渲染 ⚠ 堵死告警并给出
|
|
35
|
+
下一步动作(remote_status 逐条查 / remote_cancel 释放并发槽)——
|
|
36
|
+
用户不再只知道"卡了",还知道"卡在哪、等多久、怎么办"。
|
|
37
|
+
- 旧副机不上报 queue(向后兼容),remote_nodes 相应行缺席。
|
|
38
|
+
|
|
39
|
+
**如实说明**:exec 的取消链路核查结论是**真杀进程树**(Windows
|
|
40
|
+
`taskkill /T /F`、POSIX `kill(-pgid)`,abort → killTree 已实装)——
|
|
41
|
+
"remote_cancel 无效"的残余风险在 agent 会话卡在不检查 signal 的 await
|
|
42
|
+
与快照同步扫描(`snapshotWorkspace`),留待 #26 后续批次。
|
|
43
|
+
|
|
44
|
+
测试:引擎侧 3 条(常量同源文本断言 / maxConcurrent 默认 / queueStats 判定
|
|
45
|
+
矩阵)+ e2e 资源遥测用例扩展(queue 贯通 + starved 渲染)。全量 825 tests。
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## [0.5.0] - 2026-10-17
|
|
50
|
+
|
|
51
|
+
### 功能:远程文件工具集(提案 #25)
|
|
52
|
+
|
|
53
|
+
四个**只读**文件工具,装完即有 —— AI 可以直接读副机上的文件,**不用配
|
|
54
|
+
网盘、不用挂载、不用 SMB**:
|
|
55
|
+
|
|
56
|
+
- `remote_ls`:列目录(单层分页,目录优先,条目带类型/大小/修改时间)
|
|
57
|
+
- `remote_read`:读文件 —— 文本 ≤8MB 按行分段(`offset`/`limit` 翻页,
|
|
58
|
+
单次响应 ≤256KB,超长行截断);二进制只回 sha256 + 大小 + 头部 hex 预览
|
|
59
|
+
- `remote_grep`:副机本地全文搜索(纯 JS 逐文件扫,**不 spawn 外部 grep**,
|
|
60
|
+
Windows 没有);上限 2000 文件 / 单文件 2MB / 命中 ≤200 / 总时长 10s,
|
|
61
|
+
达到上限如实报告 `truncated`
|
|
62
|
+
- `remote_tree`:目录树摘要(深度 1-6 默认 3,条目 ≤2000)—— 扫描在副机
|
|
63
|
+
跑完只回结构,50GB 目录也不搬数据
|
|
64
|
+
|
|
65
|
+
架构与安全:
|
|
66
|
+
|
|
67
|
+
- 独立协议帧 `fs/req`(hub→node)↔ `fs/res`(node→hub),带 `replyTo` 关联
|
|
68
|
+
与 15s 超时;副机断连即清理全部等待者。**不进任务队列、不占并发槽** ——
|
|
69
|
+
一条慢命令锁死队列的形态在这里结构性不存在
|
|
70
|
+
- 安全模型与任务链路同一条底线:路径 `realpathSync.native`(展开 8.3 短名
|
|
71
|
+
与符号链接)后与 `roots` 前缀比对;`denyPaths` 在枚举与读取两层生效
|
|
72
|
+
(被禁条目连名字都不上报);符号链接只列条目不解引用;整个模块没有写动词
|
|
73
|
+
- `fs/req` 加入 E2E 必需加密帧集:relay 模式下文件路径与内容对中继不可见
|
|
74
|
+
|
|
75
|
+
测试:`test/fs-service.test.js` 14 条单测 + hub⇄worker e2e 2 条(真实通道
|
|
76
|
+
四工具贯通 + relay 窃听器验证文件内容/文件名不可见)+ 工具收口与 inject
|
|
77
|
+
契约断言同步(8 → 12 个工具)。
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
12
81
|
## [0.4.0] - 2026-10-17
|
|
13
82
|
|
|
14
83
|
### 验证:`agent` 链路两台真机确认通过
|
package/README.en.md
CHANGED
|
@@ -235,11 +235,25 @@ Once installed you **don't need to memorize commands** — just talk:
|
|
|
235
235
|
| "What artifacts are there?" | `remote_manifest` |
|
|
236
236
|
| "Pull report.pdf over" | `remote_pull` |
|
|
237
237
|
| "Cancel that task" | `remote_cancel` |
|
|
238
|
+
| "List what's in D:\BigData on the worker" | `remote_ls` |
|
|
239
|
+
| "Read that config.json on the worker" | `remote_read` |
|
|
240
|
+
| "Search the worker's 50GB folder for this term" | `remote_grep` |
|
|
241
|
+
| "What does that folder on the worker look like?" | `remote_tree` |
|
|
238
242
|
|
|
239
243
|
There is also a Web panel (sidebar → "远程同事"): node list (with CPU / memory
|
|
240
244
|
/ load badges), task dispatch, task states, result summaries, workspace
|
|
241
245
|
changes, change diffs, final artifacts, log tail.
|
|
242
246
|
|
|
247
|
+
> **Queue starvation has an alarm** (defense added after a real incident on
|
|
248
|
+
> 2026-10-17): every 30s the worker reports queue health along with resource
|
|
249
|
+
> telemetry (queued / running / longest wait). When "tasks are queued AND all
|
|
250
|
+
> concurrency slots are busy AND the oldest has waited over 2 minutes",
|
|
251
|
+
> `remote_nodes` shows a ⚠ starvation warning on that node with the suggested
|
|
252
|
+
> next step (remote_status to inspect, remote_cancel to free a slot). The
|
|
253
|
+
> default concurrency is raised 2 → 4 and the default exec timeout tightened
|
|
254
|
+
> from 1 hour to 10 minutes — a single slow command can no longer easily lock
|
|
255
|
+
> up the whole worker.
|
|
256
|
+
|
|
243
257
|
### Tool reference
|
|
244
258
|
|
|
245
259
|
<details>
|
|
@@ -256,7 +270,7 @@ changes, change diffs, final artifacts, log tail.
|
|
|
256
270
|
| `mode` | `agent` (worker starts an independent agent session, default) / `exec` (run a command directly) |
|
|
257
271
|
| `model` | Optional. Model the worker should use (`mode=agent` only): `{model:"deepseek-chat"}` or `{provider:"openai",model:"gpt-4o"}`. Selection only, no credentials —— the worker runs it with its own provider config; unsupported on headless backends |
|
|
258
272
|
| `outputs` | Glob of expected outputs (relative to the workspace), used to filter the artifact manifest |
|
|
259
|
-
| `timeoutMs` | Worker-side timeout, 1 hour
|
|
273
|
+
| `timeoutMs` | Worker-side timeout. Defaults by mode: **exec 10 minutes, agent 1 hour** (one slow exec should not pin the worker for an hour) |
|
|
260
274
|
| `denyPaths` | Extra absolute paths to deny |
|
|
261
275
|
| `artifactRules` | Override built-in artifact role classification, e.g. `{ intermediate: ["build/**"] }` |
|
|
262
276
|
|
|
@@ -265,6 +279,40 @@ changes, change diffs, final artifacts, log tail.
|
|
|
265
279
|
|
|
266
280
|
</details>
|
|
267
281
|
|
|
282
|
+
### Reading files on the worker (no network drive needed)
|
|
283
|
+
|
|
284
|
+
Four read-only file tools (`remote_ls` / `remote_read` / `remote_grep` /
|
|
285
|
+
`remote_tree`) work out of the box: the AI can list directories, read files,
|
|
286
|
+
run full-text search, and inspect folder structure — **no network share, no
|
|
287
|
+
mount, no SMB setup**. Scan-type operations (grep/tree) run locally on the
|
|
288
|
+
worker and only the results cross the wire — a 50GB folder never moves.
|
|
289
|
+
|
|
290
|
+
<details>
|
|
291
|
+
<summary>File tool parameters and boundaries</summary>
|
|
292
|
+
|
|
293
|
+
| Tool | Parameters | Meaning |
|
|
294
|
+
|---|---|---|
|
|
295
|
+
| `remote_ls` | `path` (required), `limit`, `offset` | List a directory (single level, paged, directories first) |
|
|
296
|
+
| `remote_read` | `path` (required), `offset`, `limit` | Text files ≤8MB are paged line by line (≤256KB per response); binaries return only sha256 + size + hex head preview |
|
|
297
|
+
| `remote_grep` | `path`, `pattern` (required), `glob`, `regex`, `ignoreCase`, `maxMatches` | Full-text search on the worker (pure JS, no external grep spawned); caps: 2000 files / 2MB per file / 200 matches / 10 s |
|
|
298
|
+
| `remote_tree` | `path` (required), `depth` | Directory-tree summary (depth 1-6, default 3; ≤2000 entries) |
|
|
299
|
+
|
|
300
|
+
**Boundaries (same bottom line as the task pipeline)**: read-only — the module
|
|
301
|
+
has no write verb; paths must be absolute and inside the node's `roots`,
|
|
302
|
+
anything else is rejected (`workspace-denied`); `denyPaths` applies too (denied
|
|
303
|
+
entries are not even reported by name); symbolic links are listed but never
|
|
304
|
+
followed. File operations travel on a dedicated `fs/req`↔`fs/res` channel —
|
|
305
|
+
**they never enter the task queue or occupy concurrency slots** — and under
|
|
306
|
+
relay mode they are **end-to-end encrypted** like task frames (the relay sees
|
|
307
|
+
ciphertext only).
|
|
308
|
+
|
|
309
|
+
**Combining with manual setups**: for "AI looks up / searches a file" use
|
|
310
|
+
these tools (zero config, works across NATs); for "browse huge media files
|
|
311
|
+
like a local drive" SMB/network drives remain the right tool — they
|
|
312
|
+
complement each other.
|
|
313
|
+
|
|
314
|
+
</details>
|
|
315
|
+
|
|
268
316
|
---
|
|
269
317
|
|
|
270
318
|
## Security
|
package/README.md
CHANGED
|
@@ -208,11 +208,22 @@ ack 自动按明文兼容旧中继继续),安全边界与已知局限见
|
|
|
208
208
|
| 「有哪些成品?」 | `remote_manifest` |
|
|
209
209
|
| 「把 report.pdf 拉过来」 | `remote_pull` |
|
|
210
210
|
| 「取消那个任务」 | `remote_cancel` |
|
|
211
|
+
| 「看看副机 D:\BigData 里有什么」 | `remote_ls` |
|
|
212
|
+
| 「读一下副机上那个 config.json」 | `remote_read` |
|
|
213
|
+
| 「在副机 50GB 目录里搜这个词」 | `remote_grep` |
|
|
214
|
+
| 「副机那个文件夹结构长什么样」 | `remote_tree` |
|
|
211
215
|
|
|
212
216
|
另有一个 Web 面板(侧栏「远程同事」):节点列表(含 CPU/内存/负载与仓库清单)、
|
|
213
217
|
派发任务、**待审批卡**(exec 任务默认挂起,批准才下发)、任务状态、结果摘要、
|
|
214
218
|
工作区变更、**变更 diff**(文本文件改动的 unified diff)、成品清单、日志尾巴。
|
|
215
219
|
|
|
220
|
+
> **队列堵死有告警**(2026-10-17 实测事故的防线):副机每 30s 随资源遥测上报
|
|
221
|
+
> 队列健康(排队数 / 并发占用 / 最久等待)。当"有排队且并发占满且最久已等
|
|
222
|
+
> 超 2 分钟"时,`remote_nodes` 会在该节点行打 ⚠ 堵死告警并给出下一步动作
|
|
223
|
+
> (remote_status 逐条查、remote_cancel 释放并发槽)。默认并发已从 2 提到 4,
|
|
224
|
+
> 一条慢 exec 的默认超时也从 1 小时收紧到 10 分钟 —— 单条慢命令锁死整台
|
|
225
|
+
> 副机的概率大幅降低。
|
|
226
|
+
|
|
216
227
|
### 工具速查
|
|
217
228
|
|
|
218
229
|
<details>
|
|
@@ -229,7 +240,7 @@ ack 自动按明文兼容旧中继继续),安全边界与已知局限见
|
|
|
229
240
|
| `mode` | `agent`(副机起独立 agent 会话,默认)/ `exec`(直接跑命令)|
|
|
230
241
|
| `model` | 可选。副机用的模型(仅 `mode=agent` 生效):`{model:"deepseek-chat"}` 或 `{provider:"openai",model:"gpt-4o"}`。只传选择不传凭证 —— 副机用自己的 provider 配置跑;headless 后端不支持 |
|
|
231
242
|
| `outputs` | 期望产出物的 glob(相对工作区),用于产物清单筛选 |
|
|
232
|
-
| `timeoutMs` |
|
|
243
|
+
| `timeoutMs` | 副机侧超时。缺省按模式:**exec 10 分钟、agent 1 小时**(一条慢 exec 不该锁死副机一小时) |
|
|
233
244
|
| `denyPaths` | 额外禁止访问的绝对路径 |
|
|
234
245
|
| `artifactRules` | 覆盖内置产物角色判定,例如 `{ intermediate: ["build/**"] }` |
|
|
235
246
|
|
|
@@ -238,6 +249,34 @@ ack 自动按明文兼容旧中继继续),安全边界与已知局限见
|
|
|
238
249
|
|
|
239
250
|
</details>
|
|
240
251
|
|
|
252
|
+
### 读副机上的文件(不用配网盘)
|
|
253
|
+
|
|
254
|
+
四个只读文件工具(`remote_ls` / `remote_read` / `remote_grep` / `remote_tree`)
|
|
255
|
+
装完即有:AI 可以直接列目录、读文件、全文搜索、看目录结构 —— **不用配网盘、
|
|
256
|
+
不用挂载、不用 SMB**。扫描类操作(grep/tree)在副机本地跑完只回结果,
|
|
257
|
+
50GB 目录也不用搬数据。
|
|
258
|
+
|
|
259
|
+
<details>
|
|
260
|
+
<summary>文件工具的参数与边界</summary>
|
|
261
|
+
|
|
262
|
+
| 工具 | 参数 | 说明 |
|
|
263
|
+
|---|---|---|
|
|
264
|
+
| `remote_ls` | `path`(必填)、`limit`、`offset` | 列目录(单层分页,目录优先) |
|
|
265
|
+
| `remote_read` | `path`(必填)、`offset`、`limit` | 文本 ≤8MB 按行分段(单次 ≤256KB);二进制只回 sha256+大小+头部预览 |
|
|
266
|
+
| `remote_grep` | `path`、`pattern`(必填)、`glob`、`regex`、`ignoreCase`、`maxMatches` | 副机本地全文搜索(纯 JS,不 spawn 外部命令);上限 2000 文件 / 单文件 2MB / 命中 200 / 10 秒 |
|
|
267
|
+
| `remote_tree` | `path`(必填)、`depth` | 目录树摘要(深度 1-6,默认 3;条目 ≤2000) |
|
|
268
|
+
|
|
269
|
+
**边界(与任务链路同一条底线)**:只读,没有任何写操作;路径必须是绝对路径
|
|
270
|
+
且落在该节点 `roots` 内,超出即拒(`workspace-denied`);`denyPaths` 同样生效
|
|
271
|
+
(被禁条目连名字都不上报);符号链接只列条目不解引用。文件操作走独立的
|
|
272
|
+
`fs/req`↔`fs/res` 帧,**不进任务队列、不占并发槽**,且在 relay 模式下与任务
|
|
273
|
+
帧一样**全程端到端加密**(中继只见密文)。
|
|
274
|
+
|
|
275
|
+
**与手工方案的组合**:要"AI 点读/搜索"用本组工具(零配置、跨网可用);
|
|
276
|
+
要"资源管理器像本地盘一样翻大文件",SMB/网盘仍是正解 —— 两者互补。
|
|
277
|
+
|
|
278
|
+
</details>
|
|
279
|
+
|
|
241
280
|
---
|
|
242
281
|
|
|
243
282
|
## 安全
|
package/SECURITY.md
CHANGED
|
@@ -202,6 +202,30 @@ docs/verification-repos-routing-real.md §4③)。超长值 400 字符截断
|
|
|
202
202
|
协议里**不带模型 API key**。副机用自己的 DSH 配置与凭证 ——
|
|
203
203
|
主机只能派任务,拿不到副机的模型凭证。
|
|
204
204
|
|
|
205
|
+
### 远程文件工具:只读、roots 边界、链接不跟随(提案 #25)
|
|
206
|
+
|
|
207
|
+
四个文件工具(`remote_ls` / `remote_read` / `remote_grep` / `remote_tree`)
|
|
208
|
+
让 AI 直接读副机文件 —— 它们的安全模型:
|
|
209
|
+
|
|
210
|
+
- **只读**。`lib/node/fs-service.js` 整个模块没有任何写动词;二进制文件
|
|
211
|
+
只回 sha256 + 大小 + 64 字节头部预览,不回原始字节。
|
|
212
|
+
- **`roots` 是同一条边界**。每个入口路径先 `realpathSync.native`(展开
|
|
213
|
+
8.3 短名与符号链接,与任务工作区解析同口径)再与 `roots` 前缀比对,
|
|
214
|
+
越界即 `workspace-denied`;`denyPaths` 在**枚举层**就生效 —— 被禁
|
|
215
|
+
条目连名字都不会上报给 hub。
|
|
216
|
+
- **符号链接不跟随**。tree/grep 遍历时链接只列条目本身:roots 内一个指向
|
|
217
|
+
`C:\Users` 的链接不是逃逸口。
|
|
218
|
+
- **带宽有界**。read 单响应 ≤256KB;grep 上限 2000 文件 / 单文件 2MB /
|
|
219
|
+
命中 200 / 10 秒;tree 条目 ≤2000。副机不会被一次请求拖死。
|
|
220
|
+
- **独立通道 + 加密**。文件操作走 `fs/req`↔`fs/res` 专用帧(15s 超时,
|
|
221
|
+
断连即失败),**不进任务队列、不占并发槽**;`fs/req` 在 relay 模式下
|
|
222
|
+
属于必需加密帧集 —— 文件路径与内容对中继不可见。
|
|
223
|
+
|
|
224
|
+
注意:本组工具与 `remote_task` 共享 `roots` 的**读取**面 —— 给了
|
|
225
|
+
`roots` 就意味着主机 AI 可以读那些内容(与派任务让副机 agent 读文件
|
|
226
|
+
的既有能力等价,只是更快更直接)。不想让 AI 读某目录,把它加进
|
|
227
|
+
`denyPaths`。
|
|
228
|
+
|
|
205
229
|
### exec 两阶段审批:默认挂起,等不到人不跑(提案 #2)
|
|
206
230
|
|
|
207
231
|
`mode:"exec"` 的任务**默认 `approval:"manual"`**:hub 收到派单后挂起,
|
package/lib/hub/node-registry.js
CHANGED
|
@@ -84,6 +84,8 @@ class NodeRegistryCore {
|
|
|
84
84
|
this.pendingApprovals = new Map()
|
|
85
85
|
/** taskId → HubTask(全局索引,工具只拿得到 taskId) */
|
|
86
86
|
this.tasks = new Map()
|
|
87
|
+
/** reqId → { resolve, timer }(提案 #25:远程文件请求的等待者表) */
|
|
88
|
+
this.fsPending = new Map()
|
|
87
89
|
/** @type {import('./pull-manager.js').PullManager | undefined} */
|
|
88
90
|
this.pulls = undefined
|
|
89
91
|
/** @type {import('./relay-link.js').RelayLink | undefined} */
|
|
@@ -710,6 +712,72 @@ class NodeRegistryCore {
|
|
|
710
712
|
return false
|
|
711
713
|
}
|
|
712
714
|
|
|
715
|
+
/**
|
|
716
|
+
* 远程文件请求(提案 #25):发 fs/req 并等 fs/res,超时 fail-fast。
|
|
717
|
+
*
|
|
718
|
+
* 为什么不用既有工具的"发完就走"模式:ls/read/grep/tree 是**查询语义**,
|
|
719
|
+
* 调用方(AI 工具)必须拿到结果才有意义 —— 所以这里把"发帧 + 等 replyTo"
|
|
720
|
+
* 包成一个 promise。超时拒收而不是无限等:节点掉线 / 帧丢失时调用方拿到
|
|
721
|
+
* fs-timeout 而不是永久挂起。
|
|
722
|
+
*
|
|
723
|
+
* @returns {Promise<object>} node 回的 result(或 reject {code, message})
|
|
724
|
+
*/
|
|
725
|
+
fsRequest(nodeId, op, params = {}, { timeoutMs = 15_000 } = {}) {
|
|
726
|
+
return new Promise((resolve, reject) => {
|
|
727
|
+
const rec = this.nodes.get(nodeId)
|
|
728
|
+
if (!rec?.online) {
|
|
729
|
+
reject(Object.assign(new Error(`node not online: ${nodeId}`), { code: ERROR_CODE.TASK_UNKNOWN }))
|
|
730
|
+
return
|
|
731
|
+
}
|
|
732
|
+
const env = envelope(HUB_TO_NODE.FS_REQ, { op, ...params })
|
|
733
|
+
const reqId = env.id
|
|
734
|
+
const timer = setTimeout(() => {
|
|
735
|
+
this.fsPending.delete(reqId)
|
|
736
|
+
this.log('warn', 'fs request timeout', { nodeId, op, reqId, timeoutMs })
|
|
737
|
+
reject(Object.assign(new Error(`fs ${op} timed out after ${timeoutMs}ms`), { code: ERROR_CODE.FS_TIMEOUT }))
|
|
738
|
+
}, timeoutMs)
|
|
739
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
740
|
+
this.fsPending.set(reqId, { resolve, reject, timer, op, nodeId })
|
|
741
|
+
const sent = this.send(nodeId, HUB_TO_NODE.FS_REQ, { op, ...params }, { id: reqId })
|
|
742
|
+
if (!sent) {
|
|
743
|
+
clearTimeout(timer)
|
|
744
|
+
this.fsPending.delete(reqId)
|
|
745
|
+
reject(Object.assign(new Error(`failed to send fs request to ${nodeId}`), { code: ERROR_CODE.TASK_UNKNOWN }))
|
|
746
|
+
}
|
|
747
|
+
})
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** fs/res 到达:按 replyTo 找等待者并唤醒(registry-ingest 的 FS_RES case 转发到这里)。 */
|
|
751
|
+
handleFsRes(nodeId, d) {
|
|
752
|
+
const reqId = typeof d.replyTo === 'string' ? d.replyTo : ''
|
|
753
|
+
const waiter = reqId !== '' ? this.fsPending.get(reqId) : undefined
|
|
754
|
+
if (!waiter) {
|
|
755
|
+
// 迟到的响应(超时后才回来):丢弃即可,等待者已经拿到 fs-timeout
|
|
756
|
+
this.log('info', 'late fs response dropped', { nodeId, reqId })
|
|
757
|
+
return
|
|
758
|
+
}
|
|
759
|
+
clearTimeout(waiter.timer)
|
|
760
|
+
this.fsPending.delete(reqId)
|
|
761
|
+
if (d.ok === true) {
|
|
762
|
+
waiter.resolve(d.result ?? {})
|
|
763
|
+
} else {
|
|
764
|
+
waiter.reject(Object.assign(
|
|
765
|
+
new Error(typeof d.message === 'string' ? d.message : 'fs request failed'),
|
|
766
|
+
{ code: typeof d.code === 'string' ? d.code : ERROR_CODE.INTERNAL },
|
|
767
|
+
))
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** 节点断连:清掉它名下还没等到 fs/res 的等待者(fail-fast,不挂调用方)。 */
|
|
772
|
+
fsCancelForNode(nodeId) {
|
|
773
|
+
for (const [reqId, w] of this.fsPending) {
|
|
774
|
+
if (w.nodeId !== nodeId) continue
|
|
775
|
+
clearTimeout(w.timer)
|
|
776
|
+
w.reject(Object.assign(new Error('node disconnected during fs request'), { code: ERROR_CODE.FS_TIMEOUT }))
|
|
777
|
+
this.fsPending.delete(reqId)
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
713
781
|
/**
|
|
714
782
|
* 发一个二进制帧给节点(header.to 由中继用来路由)。
|
|
715
783
|
* E2E 会话存在时整体封密:外层只留 t/seq/iv + to。
|
|
@@ -794,6 +862,9 @@ class NodeRegistryCore {
|
|
|
794
862
|
// 资源遥测(最近一次 node/resource;null = 尚未上报)。离线节点保留
|
|
795
863
|
// 最后一份 —— 带 at 时间戳,消费方自行判断新鲜度。
|
|
796
864
|
resource: r.resource ?? null,
|
|
865
|
+
// 队列健康快照(提案 #26-3):null = 副机未上报(旧版本)。starved=true
|
|
866
|
+
// 表示"有排队且并发占满且最久排队超 2 分钟"——派发前值得先看一眼。
|
|
867
|
+
queue: r.queue ?? null,
|
|
797
868
|
// 仓库清单(提案 #13):路由提示。remote_nodes 给名字列表供选型,
|
|
798
869
|
// repo 路由细节在 pickNodeByRepo。
|
|
799
870
|
repos: Array.isArray(r.repos) ? r.repos.map((x) => x.name) : [],
|
|
@@ -813,6 +884,8 @@ class NodeRegistryCore {
|
|
|
813
884
|
this.log('info', 'node offline', { nodeId, reason })
|
|
814
885
|
// 断连时在途的产物分块必然残缺,直接失败掉,别让临时文件挂着
|
|
815
886
|
this.pulls?.onNodeOffline?.(nodeId)
|
|
887
|
+
// 在途的远程文件请求同样 fail-fast(提案 #25):等不到 fs/res 就别挂调用方
|
|
888
|
+
this.fsCancelForNode(nodeId)
|
|
816
889
|
}
|
|
817
890
|
|
|
818
891
|
startHeartbeat() {
|
|
@@ -163,6 +163,18 @@ export function withIngest(Base) {
|
|
|
163
163
|
memTotalMb: num(d.memTotalMb),
|
|
164
164
|
at: Date.now(),
|
|
165
165
|
}
|
|
166
|
+
// 队列健康快照(提案 #26-3,新副机随帧搭车)。旧副机不发 queue ——
|
|
167
|
+
// 保留 rec.queue 原值(可能是 undefined),remote_nodes 按 null 展示。
|
|
168
|
+
const q = d.queue && typeof d.queue === 'object' && !Array.isArray(d.queue) ? d.queue : null
|
|
169
|
+
if (q) {
|
|
170
|
+
rec.queue = {
|
|
171
|
+
queued: num(q.queued) ?? 0,
|
|
172
|
+
running: num(q.running) ?? 0,
|
|
173
|
+
maxConcurrent: num(q.maxConcurrent) ?? 0,
|
|
174
|
+
oldestQueuedMs: num(q.oldestQueuedMs) ?? 0,
|
|
175
|
+
starved: q.starved === true,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
166
178
|
return
|
|
167
179
|
}
|
|
168
180
|
|
|
@@ -201,6 +213,12 @@ export function withIngest(Base) {
|
|
|
201
213
|
case NODE_TO_HUB.PONG:
|
|
202
214
|
return
|
|
203
215
|
|
|
216
|
+
case NODE_TO_HUB.FS_RES:
|
|
217
|
+
// 远程文件响应(提案 #25):按 replyTo 唤醒 hub 侧的 fsRequest 等待者。
|
|
218
|
+
// 加密帧 —— fs 结果(目录结构 / 文件内容)不许明文过中继。
|
|
219
|
+
this.handleFsRes(nodeId, d)
|
|
220
|
+
return
|
|
221
|
+
|
|
204
222
|
case NODE_TO_HUB.ERROR:
|
|
205
223
|
this.log('warn', 'node reported error', { nodeId, code: d.code, taskId: d.taskId })
|
|
206
224
|
if (d.code === ERROR_CODE.E2E_REQUIRED) {
|