dsh-remote-agent 0.5.0 → 0.7.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,73 @@
9
9
 
10
10
  ---
11
11
 
12
+ ## [0.7.0] - 2026-10-17
13
+
14
+ ### 功能:模型自述 + headless 指定模型派发前拦截(提案 #27)
15
+
16
+ 副机此前"只有它自己知道有哪些模型"—— 面板让用户自由填 provider/model,
17
+ 填错要等任务跑到副机才失败。本版先解决可靠可达的两半:
18
+
19
+ - **副机自述**:`RESOURCE` 帧随车上报 `runnerKind`(in-process/headless)
20
+ 与 `defaultModel`(来自 DSH `agentDefaultModel.currentSelection()`,
21
+ **只报 provider 键名 + model id,不碰任何凭证**;取不到一律 null,
22
+ 尽力而为不影响遥测帧)。hub 摄入清洗后 `remote_nodes` 透出并在节点行
23
+ 渲染("后端 in-process · 默认模型 official/deepseek-chat")。
24
+ - **派发前拦截**:`remote_task` 在 `mode=agent` + 显式 `model` + 目标节点
25
+ 为 headless 后端时**立即报错**(旧链路要等任务跑到副机才失败,浪费
26
+ 一轮往返 + 烧一个 taskId)。dryRun 与真实派发同一条校验路径。
27
+ - **dryRun 回显模型**:预览里给解析后的 `{provider, model}` 与
28
+ `modelSource`(用户指定 / 副机默认 / 未指定)—— 派之前就知道用
29
+ 什么模型。
30
+
31
+ **范围收敛(如实说明)**:完整的"可用模型清单 + 面板下拉"本版不做 ——
32
+ in-process 后端没有稳定的清单 API(DSH 内部的 `model/list` 走
33
+ app-server 协议面,fiber 服务摸不到;探测 llm-pi-ai 内部结构太脆弱)。
34
+ 等 DSH 把清单暴露成稳定服务 API 后补(面板届时渲染下拉,第一项
35
+ "跟随副机默认",数据链路本版已铺好)。
36
+
37
+ 测试:`test/model-selfreport.test.js` 3 条 + e2e 2 条(自述贯通 /
38
+ headless 拦截 / dryRun 回显)+ 修一处脆弱断言(applySync 窗口锚定义处)。
39
+
40
+ ---
41
+
42
+ ## [0.6.0] - 2026-10-17
43
+
44
+ ### 修复:单条慢命令锁死整台副机(提案 #26,实测事故防线)
45
+
46
+ 实测事故:两条慢 exec 占满 `maxConcurrent=2` 后,后续任务**全部 queued 且一条
47
+ 都不执行**;重启副机后重派同样命令再次锁死。根因是 `maxConcurrent=2` 与
48
+ exec 默认超时 1 小时的组合 —— 一条卡住的命令 = 整台副机一小时不可用。
49
+
50
+ **行为变更(默认值)**:
51
+
52
+ - **exec / agent 默认超时分开**:exec 10 分钟(绝大多数命令 1 分钟内结束,
53
+ 10 分钟足够暴露"命令本身有问题")、agent 保持 1 小时。工具层与副机兜底
54
+ **同源常量**(`task-engine.js` 导出,`tools.js` 直接 import),显式
55
+ `timeoutMs` 仍然优先。
56
+ - **maxConcurrent 默认 2 → 4**:2 太脆 —— 两条慢任务就能锁死整台机器;
57
+ 4 让"两条慢 + 两条快"正常共存。配置项保留,按内存调整。
58
+
59
+ **可见性:队列健康遥测(#26-3)**:
60
+
61
+ - 副机 `TaskEngine.queueStats()` 每 30s 随资源遥测帧搭车上报:
62
+ `{queued, running, maxConcurrent, oldestQueuedMs, starved}`。
63
+ `starved` = 有排队 + 并发占满 + 最久排队超 2 分钟。
64
+ - `remote_nodes` 透出 `queue` 字段;starved 节点渲染 ⚠ 堵死告警并给出
65
+ 下一步动作(remote_status 逐条查 / remote_cancel 释放并发槽)——
66
+ 用户不再只知道"卡了",还知道"卡在哪、等多久、怎么办"。
67
+ - 旧副机不上报 queue(向后兼容),remote_nodes 相应行缺席。
68
+
69
+ **如实说明**:exec 的取消链路核查结论是**真杀进程树**(Windows
70
+ `taskkill /T /F`、POSIX `kill(-pgid)`,abort → killTree 已实装)——
71
+ "remote_cancel 无效"的残余风险在 agent 会话卡在不检查 signal 的 await
72
+ 与快照同步扫描(`snapshotWorkspace`),留待 #26 后续批次。
73
+
74
+ 测试:引擎侧 3 条(常量同源文本断言 / maxConcurrent 默认 / queueStats 判定
75
+ 矩阵)+ e2e 资源遥测用例扩展(queue 贯通 + starved 渲染)。全量 825 tests。
76
+
77
+ ---
78
+
12
79
  ## [0.5.0] - 2026-10-17
13
80
 
14
81
  ### 功能:远程文件工具集(提案 #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>
@@ -258,9 +268,9 @@ changes, change diffs, final artifacts, log tail.
258
268
  | `dryRun` | Preview only: returns which node/workspace/mode would be used, computed by the exact same routing + validation as a real dispatch |
259
269
  | `workspace` | Absolute path on the worker, must be inside the node's `roots`. Defaults when omitted |
260
270
  | `mode` | `agent` (worker starts an independent agent session, default) / `exec` (run a command directly) |
261
- | `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 |
271
+ | `model` | Optional. Model the worker should use (`mode=agent` only): `{model:"deepseek-chat"}` or `{provider:"openai",model:"gpt-4o"}`. Selection only, no credentials. **Not supported on headless backends** (rejected before dispatch; the worker then uses its default model). `dryRun` echoes the resolved model and its source; the worker's default model is shown by `remote_nodes` |
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>
@@ -231,9 +238,9 @@ ack 自动按明文兼容旧中继继续),安全边界与已知局限见
231
238
  | `dryRun` | 只出预览不派发:返回会派到哪个节点/工作区/模式,路由与校验和正式派发同一条路径 |
232
239
  | `workspace` | 副机上的绝对路径,必须在节点 `roots` 内。省略用默认 |
233
240
  | `mode` | `agent`(副机起独立 agent 会话,默认)/ `exec`(直接跑命令)|
234
- | `model` | 可选。副机用的模型(仅 `mode=agent` 生效):`{model:"deepseek-chat"}` 或 `{provider:"openai",model:"gpt-4o"}`。只传选择不传凭证 —— 副机用自己的 provider 配置跑;headless 后端不支持 |
241
+ | `model` | 可选。副机用的模型(仅 `mode=agent` 生效):`{model:"deepseek-chat"}` 或 `{provider:"openai",model:"gpt-4o"}`。只传选择不传凭证 —— 副机用自己的 provider 配置跑。**headless 后端不支持指定模型**(派发前就会被拦,副机固定用默认模型);`dryRun` 会回显解析后的模型与来源,副机的默认模型见 `remote_nodes` |
235
242
  | `outputs` | 期望产出物的 glob(相对工作区),用于产物清单筛选 |
236
- | `timeoutMs` | 副机侧超时,默认 1 小时 |
243
+ | `timeoutMs` | 副机侧超时。缺省按模式:**exec 10 分钟、agent 1 小时**(一条慢 exec 不该锁死副机一小时) |
237
244
  | `denyPaths` | 额外禁止访问的绝对路径 |
238
245
  | `artifactRules` | 覆盖内置产物角色判定,例如 `{ intermediate: ["build/**"] }` |
239
246
 
@@ -0,0 +1,78 @@
1
+ # 交接:dsh-remote-agent 移交 DSH 接手(2026-10-17)
2
+
3
+ **移交方**:WorkBuddy 侧会话(用户积分耗尽,项目全权交 DSH)
4
+ **移交时状态**:`main` = `ae2ffe5`(即本文档提交自身;v0.7.0 定版,829 tests + lint 全绿,CI 八 job 全绿,未发布。local == origin == `ae2ffe5`)
5
+
6
+ ---
7
+
8
+ ## 0. DSH 接手第一件事:发 v0.7.0
9
+
10
+ 代码与文档已在 main,**发布三通道还没做**。按顺序:
11
+
12
+ ```bash
13
+ cd "D:\Deepseek Harness file\dsh-remote-agent"
14
+ # 1) CI 先绿(八 job:lint / packaging / check×4 / windows+macos 抖动 rerun)
15
+ gh run list --limit 1
16
+ # 2) annotated tag(gh release create 直接 --target 会 422,必须先建 tag)
17
+ TAGSHA=$(gh api repos/zbzbhs/dsh-remote-agent/git/tags -f tag=v0.7.0 \
18
+ -f message="v0.7.0 模型自述 + headless 派发前拦截(提案 #27)" \
19
+ -f object=$(git rev-parse HEAD) -f type=commit --jq '.sha')
20
+ gh api repos/zbzbhs/dsh-remote-agent/git/refs -f ref=refs/tags/v0.7.0 -f sha=$TAGSHA
21
+ gh release create v0.7.0 --title "v0.7.0 — ..." --notes-file <notes>
22
+ # 3) npm(granular bypass-2FA token 在 DSH 运行机的 ~/.npmrc;必须显式官方源,本机默认 registry 是 npmmirror)
23
+ npm publish --registry=https://registry.npmjs.org --access public
24
+ sleep 60 && npm view dsh-remote-agent version --registry=https://registry.npmjs.org
25
+ ```
26
+
27
+ Release notes 素材直接用 CHANGELOG 的 [0.7.0] 段。
28
+
29
+ ---
30
+
31
+ ## 1. 提案池与批次状态
32
+
33
+ | # | 内容 | 状态 |
34
+ |---|---|---|
35
+ | #25 | 远程文件工具集(remote_ls/read/grep/tree) | ✅ 已发布 v0.5.0 |
36
+ | #26 | 队列堵死防线(超时分模式/并发4/饥饿告警) | ✅ 主体已发布 v0.6.0;**剩余 #26-4 面板"运行中"分组、#26-5 agent 取消真打断+快照中断** |
37
+ | #27 | 模型下拉 | ✅ 代码+文档完成(v0.7.0 待发);**剩余面板下拉**(等 DSH 把 `model/list` 暴露成稳定的 fiber 服务 API;hub 侧数据链路已铺好:RESOURCE 帧 `defaultModel` → `remote_nodes` → 面板) |
38
+
39
+ 提案原文与评估结论:工作区 `dsh-feature-proposals-2026-10-17-code-sauce.md`、
40
+ `dsh-feature-proposals-2026-10-16.md`(#25 节末有完成记录)。
41
+
42
+ ### 建议接手顺序
43
+ 1. 发 v0.7.0(上面第 0 节)
44
+ 2. **#26-4/#26-5**(队列残余风险,副机侧为主,不依赖 DSH 新 API)
45
+ 3. #27 面板下拉(等 `model/list` 进 fiber 服务面;届时副机加 models 清单上报,hub/面板只做消费)
46
+ 4. EPERM 原子写 CI 抖动**立项修**(templates.js 的 rename EPERM,Windows runner 上已犯四次,每次 rerun 绿——是真实缺陷不是幻觉)
47
+ 5. `launch/` 目录的发帖文案已备好(等用户发话再动)
48
+
49
+ ---
50
+
51
+ ## 2. 工程纪律(踩过坑固化的,务必遵守)
52
+
53
+ 1. **`npm run check` 不含 lint**(= packaging + doc-drift + tests)。推送前必须单独 `npm run lint`。
54
+ 2. **git push 先试常规**(`timeout 25 git push`);不通走 API 复刻:`node "C:\Users\MR\AppData\Local\Temp\apipush\push-p2.mjs"`(⚠ 传 Windows 路径,Git Bash 的 `/c/...` node 不认)。API 推送后本地 tracking ref 会被沙箱**即时回滚**(`git update-ref` 成功后同一条 `&&` 链里 `rev-parse` 已是旧值,status 显示假 `[ahead N]`——远端其实是新的,先用 `gh api repos/<o>/<r>/commits/main` 核实)。修复:`sed -i` 直改 `.git/packed-refs` 单行(**sha 在前 ref 在后**)+ `rm -f .git/refs/remotes/origin/main` 清掉会压住 packed 的松散 ref,且完整 sha 必须 `git rev-parse` 取值、禁止手打。
55
+ 3. **只 `git add` 自己的文件**——并行会话可能同仓库工作,`git add -A` 会扫走别人 WIP。
56
+ 4. **测试跨平台双坑**(Windows 本地假绿、CI 才挂):
57
+ - 树结构子串断言用 `"name":"xxx"` 精确匹配(Linux symlink 创建成功会让 `link-to-secret` 命中裸 `secret`)
58
+ - macOS `os.tmpdir()` 是符号链接(/var → /private/var),realpath 归一后绝对路径前缀会变——路径断言用 `endsWith` 相对段
59
+ - 文本断言锚**定义处**不锚调用点(`indexOf('applySync(')` 命中 `this.applySync(...)` 调用点,固定长度窗口会随文件增删漂移——本次交接前刚修一例)
60
+ 5. **每个 doc-drift 的 read() 新目标**要同步 `test/doc-drift-selfcheck.test.js` 的复制清单(lib/node/ 整目录已覆盖,lib/ 根下新文件要单列)。
61
+ 6. CI 已知抖动:Windows templates.js EPERM 原子写(见上)、偶发 diag:inject runner 失败——rerun 即绿,别当代码问题查。
62
+
63
+ ## 3. 架构速览(关键决策,勿动摇)
64
+
65
+ - 副机是任务状态权威;hub 崩溃靠 task/sync 对账,reconcile 不重跑 completed。
66
+ - 中继只见密文(E2E 默认开,`relayE2E:false` 可关;已知局限:主动降级 + enrollToken 冒充,SECURITY.md 如实钉住)。
67
+ - 凭证不离开副机;#27 起副机只自述 provider 键名 + model id。
68
+ - roots 是安全边界(realpath 展开 8.3/符号链接后前缀比对);denyPaths 枚举层生效。
69
+ - 产物语义:outputs 是扫描过滤器,不是 final 提升器(final 由内置扩展名表 + artifactRules + dist/ 决定)。
70
+ - #25 文件工具走独立 `fs/req`↔`fs/res` 帧,**不进任务队列不占并发槽**。
71
+ - 跨版本兼容模式:新字段(queue/runnerKind/defaultModel)缺失时消费方按 null 处理,旧副机/旧 hub 混跑不炸。
72
+
73
+ ## 4. 关键文件
74
+
75
+ - 副机:`lib/node/index.js`(装配/帧分发/readDefaultModel)、`task-engine.js`(队列/超时常量/queueStats)、`fs-service.js`(#25)、`runner.js`(in-process/headless)
76
+ - hub:`lib/hub/tools.js`(12 工具)、`node-registry.js`(fsPending/列表投影)、`registry-ingest.js`(RESOURCE 摄入 + queue/defaultModel 清洗)
77
+ - 测试:`test/fs-service.test.js`、`test/model-selfreport.test.js`、`test/engine-e2e.test.js`、`test/hub-worker-e2e.test.js`(真通道 e2e 主战场)
78
+ - 记忆/历史:工作区 `.workbuddy/memory/2026-10-15.md` ~ `2026-10-17.md`(含全部踩坑链)
@@ -862,6 +862,13 @@ 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,
868
+ // 执行后端 + 默认模型(提案 #27):面板显示与派发前校验的依据。
869
+ // null = 副机未上报或未配置默认模型。
870
+ runnerKind: typeof r.runnerKind === 'string' ? r.runnerKind : null,
871
+ defaultModel: r.defaultModel && typeof r.defaultModel === 'object' ? r.defaultModel : null,
865
872
  // 仓库清单(提案 #13):路由提示。remote_nodes 给名字列表供选型,
866
873
  // repo 路由细节在 pickNodeByRepo。
867
874
  repos: Array.isArray(r.repos) ? r.repos.map((x) => x.name) : [],
@@ -163,6 +163,31 @@ 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
+ }
178
+ // 执行后端 + 默认模型(提案 #27):面板显示与派发前校验的依据。
179
+ // 每帧整体替换(副机是这一事实的权威);旧副机不上报则保留原值。
180
+ if (typeof d.runnerKind === 'string' && d.runnerKind !== '') rec.runnerKind = d.runnerKind.slice(0, 32)
181
+ const dm = d.defaultModel && typeof d.defaultModel === 'object' && !Array.isArray(d.defaultModel) ? d.defaultModel : null
182
+ if (dm && typeof dm.model === 'string' && dm.model !== '') {
183
+ rec.defaultModel = {
184
+ provider: typeof dm.provider === 'string' ? dm.provider.slice(0, 64) : '',
185
+ model: dm.model.slice(0, 128),
186
+ }
187
+ } else {
188
+ // 显式 null 是合法上报(副机没配默认模型)—— 清掉旧值避免误导
189
+ if (d.defaultModel === null) rec.defaultModel = null
190
+ }
166
191
  return
167
192
  }
168
193
 
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,27 @@ 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
+ },
437
+ // 执行后端 + 默认模型(提案 #27):副机未上报时为 null。
438
+ runnerKind: { type: 'string' },
439
+ defaultModel: {
440
+ type: 'object',
441
+ properties: {
442
+ provider: { type: 'string' },
443
+ model: { type: 'string' },
444
+ },
445
+ },
422
446
  },
423
447
  required: ['nodeId', 'online'],
424
448
  },
@@ -441,6 +465,20 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
441
465
  if (n.resource) {
442
466
  lines.push(` 资源: CPU ${n.resource.cpuPct ?? '—'}% · 内存 ${n.resource.memUsedMb ?? '—'}/${n.resource.memTotalMb ?? '—'}MB · load1 ${n.resource.load1 ?? '—'}`)
443
467
  }
468
+ // 队列健康(提案 #26-3):starved 是"该去副机看看卡在哪"的强信号
469
+ if (n.queue) {
470
+ if (n.queue.starved === true) {
471
+ const waited = Math.round((n.queue.oldestQueuedMs ?? 0) / 60000)
472
+ lines.push(` ⚠ 队列疑似堵死:${n.queue.running}/${n.queue.maxConcurrent} 并发占满,${n.queue.queued} 条排队,最久已等 ${waited >= 1 ? `${waited} 分钟` : `${n.queue.oldestQueuedMs}ms`}。用 remote_status 逐条查 running 任务,必要时 remote_cancel。`)
473
+ } else if ((n.queue.queued ?? 0) > 0) {
474
+ lines.push(` 队列: ${n.queue.running}/${n.queue.maxConcurrent} 在跑,${n.queue.queued} 条排队`)
475
+ }
476
+ }
477
+ // 后端与默认模型(提案 #27):派模型单前先看这台副机有什么
478
+ const bits = []
479
+ if (n.runnerKind) bits.push(`后端 ${n.runnerKind}`)
480
+ if (n.defaultModel?.model) bits.push(`默认模型 ${n.defaultModel.provider ? `${n.defaultModel.provider}/` : ''}${n.defaultModel.model}`)
481
+ if (bits.length > 0) lines.push(` ${bits.join(' · ')}`)
444
482
  }
445
483
  return lines.join('\n')
446
484
  }),
@@ -479,7 +517,7 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
479
517
  additionalProperties: false,
480
518
  },
481
519
  outputs: { type: 'array', items: { type: 'string' }, description: '期望产出物的 glob(相对工作区),用于产物清单筛选。' },
482
- timeoutMs: { type: 'number', description: `副机侧任务超时(毫秒),默认 ${defaultTimeoutMs}。` },
520
+ timeoutMs: { type: 'number', description: `副机侧任务超时(毫秒)。缺省按模式:exec ${EXEC_DEFAULT_TIMEOUT_MS / 60000} 分钟、agent ${AGENT_DEFAULT_TIMEOUT_MS / 60000} 分钟。` },
483
521
  denyPaths: { type: 'array', items: { type: 'string' }, description: '副机侧额外禁止访问的路径(绝对路径)。' },
484
522
  artifactRules: {
485
523
  type: 'object',
@@ -512,6 +550,16 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
512
550
  mode: { type: 'string' },
513
551
  approval: { type: 'string' },
514
552
  repo: { type: 'string' },
553
+ // 模型回显(提案 #27):dryRun 返回解析后的选择与来源;
554
+ // 正式派发路径不填(模型生效情况看 handoff)。
555
+ model: {
556
+ type: 'object',
557
+ properties: {
558
+ provider: { type: 'string' },
559
+ model: { type: 'string' },
560
+ },
561
+ },
562
+ modelSource: { type: 'string' },
515
563
  error: { type: 'object', properties: { code: { type: 'string' }, message: { type: 'string' } } },
516
564
  hint: { type: 'string' },
517
565
  },
@@ -520,10 +568,14 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
520
568
  render: renderText((_args, value) => {
521
569
  if (!value) return 'remote_task: 无返回'
522
570
  if (value.dryRun === true) {
571
+ const modelLine = value.model?.model
572
+ ? `model: ${value.model.provider ? `${value.model.provider}/` : ''}${value.model.model}(${value.modelSource ?? ''})`
573
+ : 'model: 未指定'
523
574
  return [
524
575
  '[dry-run 预览] 未派发、未落库 —— 确认无误后去掉 dryRun 正式派发',
525
576
  `node: ${value.node}${value.repo ? `(repo 命中:${value.repo})` : ''}`,
526
577
  `mode: ${value.mode ?? 'agent'}${value.approval === 'manual' ? '(将挂起等待审批)' : ''}`,
578
+ modelLine,
527
579
  `workspace: ${value.workspace ?? '(节点默认)'}`,
528
580
  value.hint ?? '',
529
581
  ].filter((l) => l !== '').join('\n')
@@ -589,13 +641,28 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
589
641
  // 只有消费者没有生产者,用户无法覆盖内置的产物角色判定)
590
642
  artifactRules: args?.artifactRules,
591
643
  constraints: {
592
- timeoutMs: num(args?.timeoutMs) ?? defaultTimeoutMs,
644
+ // 提案 #26-1:缺省按模式分开(exec 10min / agent 1h),与副机兜底
645
+ // 同源常量;defaults.timeoutMs(配置显式指定)优先于一切缺省。
646
+ timeoutMs: num(args?.timeoutMs) ?? explicitTimeoutMs
647
+ ?? (v.spec.mode === TASK_MODE.EXEC ? EXEC_DEFAULT_TIMEOUT_MS : AGENT_DEFAULT_TIMEOUT_MS),
593
648
  denyPaths: Array.isArray(args?.denyPaths) ? args.denyPaths : [],
594
649
  },
595
650
  origin: 'hub',
596
651
  })
597
652
  if (!v.ok) throw new RemoteAgentError(`invalid task: ${v.error}`, ERROR_CODE.INTERNAL)
598
653
 
654
+ // 提案 #27:模型选择的派发前校验。headless 后端不支持指定模型 ——
655
+ // 旧链路要等任务跑到副机才失败(浪费一轮往返 + 烧一个 taskId),
656
+ // 现在派发前就拦。runnerKind 是副机随资源帧自述的(#27),旧副机
657
+ // 未上报时为 null → 不拦,保留副机侧兜底报错(向后兼容)。
658
+ const recForModel = registry.getNode(nodeId)
659
+ if (v.spec.mode === TASK_MODE.AGENT && v.spec.model && recForModel?.runnerKind === 'headless') {
660
+ throw new RemoteAgentError(
661
+ `节点 ${nodeId} 是 headless 后端,不支持指定模型(副机固定用默认模型)。去掉 model 参数重试,或让副机以 in-process 后端接入。`,
662
+ ERROR_CODE.INTERNAL,
663
+ )
664
+ }
665
+
599
666
  const str = (x) => (typeof x === 'string' ? x : '')
600
667
  // 提案 #23:dry-run —— 只算"会怎么派",不落库不派帧、不烧 taskId。
601
668
  // 路由与校验和真实派发走同一条代码路径(同一处 repo 解析、同一个
@@ -604,6 +671,14 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
604
671
  // workspace 省略时副机会用 defaultWorkspace(= hello.roots[0],见
605
672
  // node/index.js)—— 预览必须镜像同一回填,否则预览和实派不一致。
606
673
  const rec0 = registry.getNode(nodeId)
674
+ // 提案 #27:回显解析后的模型与来源 —— "派之前就知道用什么模型",
675
+ // 而不是派完看 handoff。用户指定优先;否则借副机默认(若已上报)。
676
+ const selModel = v.spec.model
677
+ ? { provider: str(v.spec.model.provider), model: str(v.spec.model.model) }
678
+ : (rec0?.defaultModel && typeof rec0.defaultModel === 'object' ? { provider: str(rec0.defaultModel.provider), model: str(rec0.defaultModel.model) } : null)
679
+ const modelSource = v.spec.model
680
+ ? '用户指定'
681
+ : (rec0?.defaultModel ? '副机默认(用户未指定)' : '未指定(副机未上报默认模型)')
607
682
  return {
608
683
  taskId: '',
609
684
  node: nodeId,
@@ -613,6 +688,8 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
613
688
  approval: v.spec.approval,
614
689
  workspace: str(v.spec.workspace) || str(rec0?.hello?.roots?.[0]),
615
690
  repo: str(repoHit?.name),
691
+ ...(selModel ? { model: selModel } : {}),
692
+ modelSource: str(modelSource),
616
693
  hint: '预览即真实(与正式派发同一套路由+校验);确认无误后去掉 dryRun 正式派发。',
617
694
  }
618
695
  }
package/lib/node/index.js CHANGED
@@ -301,9 +301,34 @@ export function resolveToken(config, stateDir = '') {
301
301
  }
302
302
  }
303
303
 
304
+ /**
305
+ * 读副机当前默认模型选择(提案 #27)。
306
+ *
307
+ * 来源是 DSH 的 `agentDefaultModel` 服务(`currentSelection()` 返回 detached
308
+ * 的 provider/model/reasoning 选择)。**只报 provider 键名与 model id,
309
+ * 不碰任何凭证**。取不到(服务缺失 / 抛错 / 字段形态不对)一律 null ——
310
+ * 上报是尽力而为的遥测,绝不能影响资源帧本身。
311
+ *
312
+ * 完整的"可用模型清单"第一版不做:in-process 后端没有稳定的清单 API
313
+ * (DSH 内部的 model/list 走 app-server 协议面,fiber 服务摸不到),
314
+ * 探测 llm-pi-ai 内部结构太脆弱。等 DSH 把清单暴露成稳定 API 再补。
315
+ */
316
+ export function readDefaultModel(ctx) {
317
+ try {
318
+ const dm = typeof ctx?.get === 'function' ? ctx.get('agentDefaultModel') : undefined
319
+ const sel = dm && typeof dm.currentSelection === 'function' ? dm.currentSelection() : undefined
320
+ if (!sel || typeof sel !== 'object') return null
321
+ const provider = typeof sel.provider === 'string' && sel.provider !== '' ? sel.provider.slice(0, 64) : ''
322
+ const model = typeof sel.model === 'string' && sel.model !== '' ? sel.model.slice(0, 128) : ''
323
+ if (model === '') return null
324
+ return { provider, model }
325
+ } catch {
326
+ return null
327
+ }
328
+ }
329
+
304
330
  /** 组 hello:hub 与 relay 都靠它认这台机器。 */
305
- export function buildNodeHello(config, { roots, nodeId } = {}) {
306
- const c = asConfig(config)
331
+ export function buildNodeHello(config, { roots, nodeId } = {}) { const c = asConfig(config)
307
332
  // 不变式:产出的 hello **必须**能过 protocol.validateHello。所以显式 override 也
308
333
  // 要过一遍 sanitizeNodeId —— 调用方塞一个未清洗的 id(`''` / `'中文节点'` / `'X Y'`)
309
334
  // 会让 buildNodeHello 直接产出 hub 必拒的 hello(`hello-nodeId-invalid`),而
@@ -561,7 +586,20 @@ function startWorker(ctx, config) {
561
586
  : RESOURCE_INTERVAL_MS
562
587
  const resourceTimer = resourceIntervalMs > 0
563
588
  ? setInterval(() => {
564
- send(NODE_TO_HUB.RESOURCE, { nodeId, ...sampler.sample() })
589
+ // 队列健康快照(提案 #26-3)随资源帧搭车:engine 在下方第 7 节才
590
+ // 装配,但 interval 回调最早也在装配完成后才触发,引用安全。
591
+ // engine 尚未就绪(极端:interval 在装配中途先触发)时给空快照。
592
+ const queue = engine ? engine.queueStats() : { queued: 0, running: 0, maxConcurrent: 0, oldestQueuedMs: 0, starved: false }
593
+ // 提案 #27:执行后端与默认模型随帧自述 —— hub 派发前要知道
594
+ // "这台副机用哪个后端、默认模型是什么"(面板显示 + 派发前校验)。
595
+ // currentSelection 每帧重算(用户可在副机改默认),取不到就 null。
596
+ send(NODE_TO_HUB.RESOURCE, {
597
+ nodeId,
598
+ ...sampler.sample(),
599
+ queue,
600
+ runnerKind: runner.kind,
601
+ defaultModel: readDefaultModel(ctx),
602
+ })
565
603
  }, resourceIntervalMs)
566
604
  : undefined
567
605
  if (resourceTimer?.unref) resourceTimer.unref()
@@ -608,7 +646,8 @@ function startWorker(ctx, config) {
608
646
  caseInsensitive,
609
647
  send,
610
648
  logger,
611
- maxConcurrent: Number.isFinite(cfg.maxConcurrent) ? Math.max(1, cfg.maxConcurrent) : 2,
649
+ // 默认 4(提案 #26-2):2 太脆,两条慢任务就能锁死整台机器。
650
+ maxConcurrent: Number.isFinite(cfg.maxConcurrent) ? Math.max(1, cfg.maxConcurrent) : 4,
612
651
  })
613
652
 
614
653
  // 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.7.0",
4
4
  "type": "module",
5
5
  "description": "把另一台电脑变成你 DSH 的远程 Agent 同事:副机本地持久跑任务、独立 Agent 循环,只回传 handoff 摘要与产物清单,成品按需拉取。",
6
6
  "license": "MIT",