dsh-remote-agent 0.5.0 → 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 CHANGED
@@ -9,6 +9,43 @@
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
+
12
49
  ## [0.5.0] - 2026-10-17
13
50
 
14
51
  ### 功能:远程文件工具集(提案 #25)
package/README.en.md CHANGED
@@ -244,6 +244,16 @@ There is also a Web panel (sidebar → "远程同事"): node list (with CPU / me
244
244
  / load badges), task dispatch, task states, result summaries, workspace
245
245
  changes, change diffs, final artifacts, log tail.
246
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
+
247
257
  ### Tool reference
248
258
 
249
259
  <details>
@@ -260,7 +270,7 @@ changes, change diffs, final artifacts, log tail.
260
270
  | `mode` | `agent` (worker starts an independent agent session, default) / `exec` (run a command directly) |
261
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 |
262
272
  | `outputs` | Glob of expected outputs (relative to the workspace), used to filter the artifact manifest |
263
- | `timeoutMs` | Worker-side timeout, 1 hour by default |
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) |
264
274
  | `denyPaths` | Extra absolute paths to deny |
265
275
  | `artifactRules` | Override built-in artifact role classification, e.g. `{ intermediate: ["build/**"] }` |
266
276
 
package/README.md CHANGED
@@ -217,6 +217,13 @@ ack 自动按明文兼容旧中继继续),安全边界与已知局限见
217
217
  派发任务、**待审批卡**(exec 任务默认挂起,批准才下发)、任务状态、结果摘要、
218
218
  工作区变更、**变更 diff**(文本文件改动的 unified diff)、成品清单、日志尾巴。
219
219
 
220
+ > **队列堵死有告警**(2026-10-17 实测事故的防线):副机每 30s 随资源遥测上报
221
+ > 队列健康(排队数 / 并发占用 / 最久等待)。当"有排队且并发占满且最久已等
222
+ > 超 2 分钟"时,`remote_nodes` 会在该节点行打 ⚠ 堵死告警并给出下一步动作
223
+ > (remote_status 逐条查、remote_cancel 释放并发槽)。默认并发已从 2 提到 4,
224
+ > 一条慢 exec 的默认超时也从 1 小时收紧到 10 分钟 —— 单条慢命令锁死整台
225
+ > 副机的概率大幅降低。
226
+
220
227
  ### 工具速查
221
228
 
222
229
  <details>
@@ -233,7 +240,7 @@ ack 自动按明文兼容旧中继继续),安全边界与已知局限见
233
240
  | `mode` | `agent`(副机起独立 agent 会话,默认)/ `exec`(直接跑命令)|
234
241
  | `model` | 可选。副机用的模型(仅 `mode=agent` 生效):`{model:"deepseek-chat"}` 或 `{provider:"openai",model:"gpt-4o"}`。只传选择不传凭证 —— 副机用自己的 provider 配置跑;headless 后端不支持 |
235
242
  | `outputs` | 期望产出物的 glob(相对工作区),用于产物清单筛选 |
236
- | `timeoutMs` | 副机侧超时,默认 1 小时 |
243
+ | `timeoutMs` | 副机侧超时。缺省按模式:**exec 10 分钟、agent 1 小时**(一条慢 exec 不该锁死副机一小时) |
237
244
  | `denyPaths` | 额外禁止访问的绝对路径 |
238
245
  | `artifactRules` | 覆盖内置产物角色判定,例如 `{ intermediate: ["build/**"] }` |
239
246
 
@@ -862,6 +862,9 @@ class NodeRegistryCore {
862
862
  // 资源遥测(最近一次 node/resource;null = 尚未上报)。离线节点保留
863
863
  // 最后一份 —— 带 at 时间戳,消费方自行判断新鲜度。
864
864
  resource: r.resource ?? null,
865
+ // 队列健康快照(提案 #26-3):null = 副机未上报(旧版本)。starved=true
866
+ // 表示"有排队且并发占满且最久排队超 2 分钟"——派发前值得先看一眼。
867
+ queue: r.queue ?? null,
865
868
  // 仓库清单(提案 #13):路由提示。remote_nodes 给名字列表供选型,
866
869
  // repo 路由细节在 pickNodeByRepo。
867
870
  repos: Array.isArray(r.repos) ? r.repos.map((x) => x.name) : [],
@@ -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
 
package/lib/hub/tools.js CHANGED
@@ -34,6 +34,9 @@ import {
34
34
  newTaskId,
35
35
  validateTaskStart,
36
36
  } from '../protocol.js'
37
+ // exec/agent 分开的默认超时(提案 #26-1)——与副机兜底**同源**,
38
+ // 避免"工具说 10 分钟、副机兜底 1 小时"的分裂。
39
+ import { AGENT_DEFAULT_TIMEOUT_MS, EXEC_DEFAULT_TIMEOUT_MS } from '../node/task-engine.js'
37
40
  // 视图投影共享层:与 UI 路由用**同一份**白名单(见 lib/hub/views.js 的说明)。
38
41
  import { handoffView } from './views.js'
39
42
 
@@ -348,7 +351,7 @@ export function handoffSummaryText(task) {
348
351
  */
349
352
  export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults = {} }) {
350
353
  const disposers = []
351
- const defaultTimeoutMs = num(defaults.timeoutMs) ?? 60 * 60 * 1000
354
+ const explicitTimeoutMs = num(defaults.timeoutMs)
352
355
 
353
356
  /**
354
357
  * 注册一个工具,并给它的 `execute` **统一套上返回值净化**。
@@ -419,6 +422,18 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
419
422
  at: { type: 'number' },
420
423
  },
421
424
  },
425
+ // 队列健康快照(提案 #26-3):副机未上报(旧版本)时为 null,
426
+ // 由 dropNullForSchema 剥掉。字段全部有值(0/false 兜底)。
427
+ queue: {
428
+ type: 'object',
429
+ properties: {
430
+ queued: { type: 'number' },
431
+ running: { type: 'number' },
432
+ maxConcurrent: { type: 'number' },
433
+ oldestQueuedMs: { type: 'number' },
434
+ starved: { type: 'boolean' },
435
+ },
436
+ },
422
437
  },
423
438
  required: ['nodeId', 'online'],
424
439
  },
@@ -441,6 +456,15 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
441
456
  if (n.resource) {
442
457
  lines.push(` 资源: CPU ${n.resource.cpuPct ?? '—'}% · 内存 ${n.resource.memUsedMb ?? '—'}/${n.resource.memTotalMb ?? '—'}MB · load1 ${n.resource.load1 ?? '—'}`)
443
458
  }
459
+ // 队列健康(提案 #26-3):starved 是"该去副机看看卡在哪"的强信号
460
+ if (n.queue) {
461
+ if (n.queue.starved === true) {
462
+ const waited = Math.round((n.queue.oldestQueuedMs ?? 0) / 60000)
463
+ lines.push(` ⚠ 队列疑似堵死:${n.queue.running}/${n.queue.maxConcurrent} 并发占满,${n.queue.queued} 条排队,最久已等 ${waited >= 1 ? `${waited} 分钟` : `${n.queue.oldestQueuedMs}ms`}。用 remote_status 逐条查 running 任务,必要时 remote_cancel。`)
464
+ } else if ((n.queue.queued ?? 0) > 0) {
465
+ lines.push(` 队列: ${n.queue.running}/${n.queue.maxConcurrent} 在跑,${n.queue.queued} 条排队`)
466
+ }
467
+ }
444
468
  }
445
469
  return lines.join('\n')
446
470
  }),
@@ -479,7 +503,7 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
479
503
  additionalProperties: false,
480
504
  },
481
505
  outputs: { type: 'array', items: { type: 'string' }, description: '期望产出物的 glob(相对工作区),用于产物清单筛选。' },
482
- timeoutMs: { type: 'number', description: `副机侧任务超时(毫秒),默认 ${defaultTimeoutMs}。` },
506
+ timeoutMs: { type: 'number', description: `副机侧任务超时(毫秒)。缺省按模式:exec ${EXEC_DEFAULT_TIMEOUT_MS / 60000} 分钟、agent ${AGENT_DEFAULT_TIMEOUT_MS / 60000} 分钟。` },
483
507
  denyPaths: { type: 'array', items: { type: 'string' }, description: '副机侧额外禁止访问的路径(绝对路径)。' },
484
508
  artifactRules: {
485
509
  type: 'object',
@@ -589,7 +613,10 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
589
613
  // 只有消费者没有生产者,用户无法覆盖内置的产物角色判定)
590
614
  artifactRules: args?.artifactRules,
591
615
  constraints: {
592
- timeoutMs: num(args?.timeoutMs) ?? defaultTimeoutMs,
616
+ // 提案 #26-1:缺省按模式分开(exec 10min / agent 1h),与副机兜底
617
+ // 同源常量;defaults.timeoutMs(配置显式指定)优先于一切缺省。
618
+ timeoutMs: num(args?.timeoutMs) ?? explicitTimeoutMs
619
+ ?? (v.spec.mode === TASK_MODE.EXEC ? EXEC_DEFAULT_TIMEOUT_MS : AGENT_DEFAULT_TIMEOUT_MS),
593
620
  denyPaths: Array.isArray(args?.denyPaths) ? args.denyPaths : [],
594
621
  },
595
622
  origin: 'hub',
package/lib/node/index.js CHANGED
@@ -561,7 +561,11 @@ function startWorker(ctx, config) {
561
561
  : RESOURCE_INTERVAL_MS
562
562
  const resourceTimer = resourceIntervalMs > 0
563
563
  ? setInterval(() => {
564
- send(NODE_TO_HUB.RESOURCE, { nodeId, ...sampler.sample() })
564
+ // 队列健康快照(提案 #26-3)随资源帧搭车:engine 在下方第 7 节才
565
+ // 装配,但 interval 回调最早也在装配完成后才触发,引用安全。
566
+ // engine 尚未就绪(极端:interval 在装配中途先触发)时给空快照。
567
+ const queue = engine ? engine.queueStats() : { queued: 0, running: 0, maxConcurrent: 0, oldestQueuedMs: 0, starved: false }
568
+ send(NODE_TO_HUB.RESOURCE, { nodeId, ...sampler.sample(), queue })
565
569
  }, resourceIntervalMs)
566
570
  : undefined
567
571
  if (resourceTimer?.unref) resourceTimer.unref()
@@ -608,7 +612,8 @@ function startWorker(ctx, config) {
608
612
  caseInsensitive,
609
613
  send,
610
614
  logger,
611
- maxConcurrent: Number.isFinite(cfg.maxConcurrent) ? Math.max(1, cfg.maxConcurrent) : 2,
615
+ // 默认 4(提案 #26-2):2 太脆,两条慢任务就能锁死整台机器。
616
+ maxConcurrent: Number.isFinite(cfg.maxConcurrent) ? Math.max(1, cfg.maxConcurrent) : 4,
612
617
  })
613
618
 
614
619
  // TaskEngine.pump() 用 `void this.execute(taskId)` 起任务 —— 一旦 execute reject,
@@ -26,6 +26,23 @@ import { isDenied, resolveWorkspace, runCommand } from './exec.js'
26
26
  /** 摘要里最多保留多少条 changed 记录。 */
27
27
  const MAX_CHANGED = 500
28
28
 
29
+ /**
30
+ * 默认任务超时(提案 #26-1):**exec 与 agent 分开**。
31
+ *
32
+ * 一条 exec 卡住 = 整台副机 1 小时不可用是 #26 实测的事故形态(两条慢 exec
33
+ * 占满 maxConcurrent,队列永动不了)。exec 是跑命令,绝大多数 1 分钟内结束;
34
+ * 10 分钟足够暴露"命令本身有问题"(路径不存在/权限/巨目录)。agent 会话
35
+ * 可能真的要跑很久,保持 1 小时。
36
+ *
37
+ * tools.js 的 remote_task 默认值必须与这里**同源**(直接 import 本常量),
38
+ * 否则会出现"工具说 10 分钟、副机兜底 1 小时"的分裂。
39
+ */
40
+ export const EXEC_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000
41
+ export const AGENT_DEFAULT_TIMEOUT_MS = 60 * 60 * 1000
42
+
43
+ /** 队列饥饿判定阈值(提案 #26-3):queued>0 且并发占满且最久排队超过此时长。 */
44
+ export const QUEUE_STARVED_AFTER_MS = 2 * 60 * 1000
45
+
29
46
  export class TaskEngine {
30
47
  /**
31
48
  * @param {object} o
@@ -53,8 +70,10 @@ export class TaskEngine {
53
70
  this.logger = o.logger
54
71
  /** taskId → { controller, startedAt } */
55
72
  this.running = new Map()
56
- /** 任务并发上限,防止副机被一次派单压垮 */
57
- this.maxConcurrent = o.maxConcurrent ?? 2
73
+ /** 任务并发上限,防止副机被一次派单压垮。
74
+ * 默认 4(提案 #26-2):2 太脆 —— 两条慢任务就能锁死整台机器;
75
+ * 4 让"两条慢 + 两条快"正常共存。配置项保留,按内存调整。 */
76
+ this.maxConcurrent = o.maxConcurrent ?? 4
58
77
  this.queue = []
59
78
  /** 卸载闸门:stop() 置 true 后不再接受/启动新任务(见 stop() 注释)。 */
60
79
  this.stopped = false
@@ -319,7 +338,9 @@ export class TaskEngine {
319
338
  try {
320
339
  res = await runCommand(command, {
321
340
  cwd: rec.workspace,
322
- timeoutMs: rec.constraints?.timeoutMs ?? 60 * 60 * 1000,
341
+ // exec 默认 10 分钟(提案 #26-1,与 tools.js 同源常量);任务显式
342
+ // 传 timeoutMs 时以任务为准(长遍历大目录等场景仍可显式放宽)。
343
+ timeoutMs: rec.constraints?.timeoutMs ?? EXEC_DEFAULT_TIMEOUT_MS,
323
344
  env: rec.constraints?.extraEnv ?? {},
324
345
  signal: controller.signal,
325
346
  onData: (chunk, stream) => {
@@ -369,7 +390,7 @@ export class TaskEngine {
369
390
  // 落盘为 null 表示未指定 → 传 undefined,让 runner 走"副机默认"路径。
370
391
  // in-process 后端会把它叠加到副机默认选择上;headless 后端会明确拒绝。
371
392
  model: rec.model ?? undefined,
372
- timeoutMs: rec.constraints?.timeoutMs ?? 60 * 60 * 1000,
393
+ timeoutMs: rec.constraints?.timeoutMs ?? AGENT_DEFAULT_TIMEOUT_MS,
373
394
  signal: controller.signal,
374
395
  onData: (chunk, stream) => {
375
396
  for (const line of chunk.split(/\r?\n/)) {
@@ -491,6 +512,32 @@ export class TaskEngine {
491
512
  return map
492
513
  }
493
514
 
515
+ /**
516
+ * 队列健康快照(提案 #26-3):随资源遥测帧上报 hub。
517
+ *
518
+ * 背景(#26 实测事故):两条慢 exec 占满 maxConcurrent 后,pump() 的
519
+ * while 条件永假,后续任务全部 queued 且**在面板上不可见**——用户只知道
520
+ * "卡了",不知道"卡在哪"。这个快照让 hub/面板能说出一句话:
521
+ * "队列被 N 条运行中任务阻塞,最久已等 M 分钟"。
522
+ *
523
+ * starved 判定:有排队 + 并发占满 + 最久排队超过 QUEUE_STARVED_AFTER_MS。
524
+ * 纯读操作,绝不影响任务链路(与资源遥测同一条尽力而为不变式)。
525
+ */
526
+ queueStats(now = Date.now()) {
527
+ const queuedAt = this.queue
528
+ .map((id) => this.store.get(id)?.createdAt)
529
+ .filter((t) => typeof t === 'number' && t > 0)
530
+ const oldestQueuedMs = queuedAt.length > 0 ? Math.max(0, now - Math.min(...queuedAt)) : 0
531
+ const saturated = this.running.size >= this.maxConcurrent
532
+ return {
533
+ queued: this.queue.length,
534
+ running: this.running.size,
535
+ maxConcurrent: this.maxConcurrent,
536
+ oldestQueuedMs,
537
+ starved: this.queue.length > 0 && saturated && oldestQueuedMs > QUEUE_STARVED_AFTER_MS,
538
+ }
539
+ }
540
+
494
541
  /** 重连后向 hub 上报全部任务状态。 */
495
542
  syncAll() {
496
543
  this.send('task/sync', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-agent",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "把另一台电脑变成你 DSH 的远程 Agent 同事:副机本地持久跑任务、独立 Agent 循环,只回传 handoff 摘要与产物清单,成品按需拉取。",
6
6
  "license": "MIT",