dsh-remote-agent 0.0.0-stage → 0.4.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 +700 -0
- package/CONTRIBUTING.md +358 -0
- package/KNOWN-ISSUES.md +601 -0
- package/LICENSE +21 -0
- package/README.en.md +353 -0
- package/README.md +317 -2
- package/SECURITY.md +348 -0
- package/bin/dsh-remote-relay.js +257 -0
- package/bin/dsh-remote-setup.js +440 -0
- package/bin//345/217/214/345/207/273/350/277/220/350/241/214-/351/205/215/347/275/256/345/220/221/345/257/274.cmd +28 -0
- package/cordis.patch.yml +87 -0
- package/deploy/relay-vps/Caddyfile +24 -0
- package/deploy/relay-vps/README.md +132 -0
- package/deploy/relay-vps/dsh-remote-relay.service +47 -0
- package/deploy/relay-vps/nginx.conf +49 -0
- package/deploy/relay-vps/relay.env.example +20 -0
- package/docs/dispatch-automation-eval.md +140 -0
- package/docs/screenshots/panel-main.png +0 -0
- package/docs/screenshots/panel-task-detail.png +0 -0
- package/docs/verification-agent-e2e.md +425 -0
- package/docs/verification-repos-routing-real.md +521 -0
- package/docs/verification-resume-real.md +254 -0
- package/docs/worker-setup-pitfalls.md +266 -0
- package/lib/artifacts.js +451 -0
- package/lib/client.js +1752 -0
- package/lib/e2e.js +304 -0
- package/lib/glob.js +225 -0
- package/lib/hub/audit-log.js +161 -0
- package/lib/hub/hub-util.js +114 -0
- package/lib/hub/index.js +500 -0
- package/lib/hub/node-registry.js +943 -0
- package/lib/hub/pull-manager.js +781 -0
- package/lib/hub/registry-handshake.js +403 -0
- package/lib/hub/registry-ingest.js +346 -0
- package/lib/hub/relay-link.js +228 -0
- package/lib/hub/subagent-provider.js +424 -0
- package/lib/hub/templates.js +693 -0
- package/lib/hub/tools.js +1059 -0
- package/lib/hub/ui-routes.js +856 -0
- package/lib/hub/views.js +351 -0
- package/lib/identity.js +271 -0
- package/lib/index.js +272 -0
- package/lib/node/artifact-service.js +231 -0
- package/lib/node/diff.js +124 -0
- package/lib/node/exec.js +536 -0
- package/lib/node/hub-pin.js +71 -0
- package/lib/node/index.js +1022 -0
- package/lib/node/repos.js +134 -0
- package/lib/node/resource.js +63 -0
- package/lib/node/runner.js +792 -0
- package/lib/node/task-engine.js +712 -0
- package/lib/node/task-store.js +273 -0
- package/lib/node/transport.js +763 -0
- package/lib/pairing.js +186 -0
- package/lib/protocol.js +414 -0
- package/lib/relay-config.js +317 -0
- package/lib/relay.js +732 -0
- package/lib/setup-routes.js +313 -0
- package/lib/setup.js +522 -0
- package/lib/ws.js +466 -0
- package/package.json +103 -4
- package/profiles/README.md +573 -0
- package/profiles/hub.cordis.yml +84 -0
- package/profiles/worker.cordis.yml +60 -0
- package/scripts/check-doc-drift.mjs +785 -0
- package/scripts/check-packaging.mjs +130 -0
- package/scripts/diag-inject.mjs +243 -0
- package/scripts/e2e-live-check.mjs +181 -0
- package/scripts/relay-init.ps1 +286 -0
- package/scripts/relay-init.sh +199 -0
package/lib/hub/tools.js
ADDED
|
@@ -0,0 +1,1059 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主机侧 remote_* 工具集(零外部依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 设计原则:**主电脑只消费 handoff,不消费副机的原始日志。**
|
|
5
|
+
* 七个工具的职责边界刻意切得很窄:
|
|
6
|
+
*
|
|
7
|
+
* remote_nodes 看有哪些副机在线(信息汇聚的入口)
|
|
8
|
+
* remote_task 派一条任务(幂等 taskId,立即返回,不阻塞等结果)
|
|
9
|
+
* remote_status 看一条任务现在到哪了(进度尾巴 + 日志尾巴)
|
|
10
|
+
* remote_handoff 取完整交接卡(决策所需的一切都在这一张卡里)
|
|
11
|
+
* remote_manifest 取产物清单(先看清单,再决定拉什么)
|
|
12
|
+
* remote_pull 显式拉取成品(只有 role:final 能过河)
|
|
13
|
+
* remote_cancel 打断一条任务
|
|
14
|
+
*
|
|
15
|
+
* 所有 execute 都返回普通 JSON(output.schema 描述它),错误以
|
|
16
|
+
* RemoteAgentError 抛出,由框架统一materialize 成错误结果。
|
|
17
|
+
*/
|
|
18
|
+
import os from 'node:os'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import {
|
|
21
|
+
ERROR_CODE,
|
|
22
|
+
PROTOCOL_VERSION,
|
|
23
|
+
RemoteAgentError,
|
|
24
|
+
TASK_MODE,
|
|
25
|
+
TERMINAL_STATUS,
|
|
26
|
+
newTaskId,
|
|
27
|
+
validateTaskStart,
|
|
28
|
+
} from '../protocol.js'
|
|
29
|
+
// 视图投影共享层:与 UI 路由用**同一份**白名单(见 lib/hub/views.js 的说明)。
|
|
30
|
+
import { handoffView } from './views.js'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 按 output.schema **删掉类型不匹配的 null**(可选字段)。
|
|
34
|
+
*
|
|
35
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
36
|
+
* 为什么需要(实测踩到两次)
|
|
37
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
38
|
+
*
|
|
39
|
+
* DSH 会对工具返回值跑 `validateJsonSchemaValue(outputSchema, value)`。null 是
|
|
40
|
+
* 合法 JSON,但对声明为 `{ type: 'number' }` / `{ type: 'object' }` 的字段,
|
|
41
|
+
* null **不是**该类型 → 报:
|
|
42
|
+
* `returned invalid output: "value.startedAt" must be a number`
|
|
43
|
+
* `returned invalid output: "value.error" must be an object`
|
|
44
|
+
*
|
|
45
|
+
* 我们的共享投影层(views.js 的 exitView / errorView / taskView)出于 UI 侧的
|
|
46
|
+
* 需要会对"没有值"返回 null(UI 只是不渲染那一块)。但同一条投影也被工具复用,
|
|
47
|
+
* 于是工具调用整个失败 —— 而**任务其实已经派出去了**(有副作用却报失败)。
|
|
48
|
+
*
|
|
49
|
+
* ── 策略 ────────────────────────────────────────────────────────────────
|
|
50
|
+
* 只对**不在 required 里**的字段动手:值是 null 且 schema 说它不是 null 类型
|
|
51
|
+
* → 删掉该字段(JSON 里"缺失"与"null"对消费方差别很小,而缺失一定合法)。
|
|
52
|
+
*
|
|
53
|
+
* 对 **required** 字段不删(删了会从"类型错"变成"缺字段错",同样是失败);
|
|
54
|
+
* 那种情况属于实现 bug,应该在源头修(例如 taskView 已把时间字段改成 0)。
|
|
55
|
+
*
|
|
56
|
+
* @param {unknown} value 工具返回值
|
|
57
|
+
* @param {object} [schema] output.schema(JSON Schema 子集)
|
|
58
|
+
* @returns {unknown} 处理后的值
|
|
59
|
+
*/
|
|
60
|
+
function dropNullForSchema(value, schema) {
|
|
61
|
+
if (value === null || typeof value !== 'object') return value
|
|
62
|
+
if (!schema || typeof schema !== 'object') return value
|
|
63
|
+
|
|
64
|
+
// ── 数组:逐元素套用 items 的 schema 递归 ──────────────────────────────
|
|
65
|
+
//
|
|
66
|
+
// ⚠ 这一段是 2026-10-16 补的。原实现遇到数组**直接 return**,于是
|
|
67
|
+
// `nodes[0].resource = null` 原样出边界,DSH 报
|
|
68
|
+
//
|
|
69
|
+
// tool "remote_nodes" returned invalid output:
|
|
70
|
+
// "value.nodes[0].resource" must be an object
|
|
71
|
+
//
|
|
72
|
+
// 现象是"工具存在并执行了,但返回值不合 schema" —— 而 schema 里
|
|
73
|
+
// `resource` 只声明了 `type: 'object'`,靠"optional 的 null 会被剥掉"
|
|
74
|
+
// 这个约定来容错。**约定只有在递归到那一层时才生效。**
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
const itemSchema = schema.items && typeof schema.items === 'object' ? schema.items : null
|
|
77
|
+
if (!itemSchema) return value
|
|
78
|
+
return value.map((v) => dropNullForSchema(v, itemSchema))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const props = schema.properties && typeof schema.properties === 'object' ? schema.properties : null
|
|
82
|
+
if (!props) return value
|
|
83
|
+
const required = Array.isArray(schema.required) ? schema.required : []
|
|
84
|
+
const out = { ...value }
|
|
85
|
+
for (const [key, sub] of Object.entries(props)) {
|
|
86
|
+
if (out[key] === null) {
|
|
87
|
+
if (required.includes(key)) continue // required 的 null 要在源头修,这里不掩盖
|
|
88
|
+
const t = sub && typeof sub === 'object' ? sub.type : undefined
|
|
89
|
+
// schema 允许 null(type 数组含 'null',或显式 nullable)时保留
|
|
90
|
+
const allowsNull = t === 'null' || (Array.isArray(t) && t.includes('null')) || (sub && sub.nullable === true)
|
|
91
|
+
if (!allowsNull) delete out[key]
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
// ── 非 null 的子值:递归下去 ─────────────────────────────────────────
|
|
95
|
+
//
|
|
96
|
+
// 只处理对象/数组(标量没什么可剥的)。这一层同样不能省:
|
|
97
|
+
// `nodes[].resource = { cpuPct: null, ... }` 里的 null 也要剥,
|
|
98
|
+
// 否则 `"value.nodes[0].resource.cpuPct" must be a number`。
|
|
99
|
+
const subValue = out[key]
|
|
100
|
+
if (subValue !== null && typeof subValue === 'object' && sub && typeof sub === 'object') {
|
|
101
|
+
const next = dropNullForSchema(subValue, sub)
|
|
102
|
+
if (next !== subValue) out[key] = next
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 把工具返回值净化成 **DSH 认可的纯 JSON 值**。
|
|
110
|
+
*
|
|
111
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
112
|
+
* 为什么必须有这一层(实测踩到,代价很大)
|
|
113
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
114
|
+
*
|
|
115
|
+
* DSH 在工具返回值出边界时会跑 `walkJsonValue(value, true)`(见 asar 里的
|
|
116
|
+
* `snapshotJsonValue`)。它返回 `undefined` 的条件是:
|
|
117
|
+
*
|
|
118
|
+
* - `typeof value !== 'object'` 且不是 null/boolean/string/number
|
|
119
|
+
* → **undefined / 函数 / Symbol 全部被拒**
|
|
120
|
+
* - 数字 `!Number.isFinite(v) || Object.is(v, -0)` → **NaN / Infinity / -0 被拒**
|
|
121
|
+
* - 原型不是 `Object.prototype` 也不是 `null` → **类实例被拒**
|
|
122
|
+
* - 循环引用
|
|
123
|
+
* - 数组原型不对,或数组上挂了额外属性
|
|
124
|
+
*
|
|
125
|
+
* 一旦被拒,工具调用报 `returned invalid output: value is not lossless JSON`。
|
|
126
|
+
* **此时工具其实已经执行成功**(任务派出去了、文件拉回来了),但调用方只看到
|
|
127
|
+
* 一个序列化错误 —— 既拿不到结果,也无法判断副作用是否发生。这是最坏的一类
|
|
128
|
+
* 失败:**有副作用却报失败**。
|
|
129
|
+
*
|
|
130
|
+
* 我们踩到的具体形态:副机回传的 `accepted` 对象带类原型,直接塞进返回值就被拒。
|
|
131
|
+
*
|
|
132
|
+
* ── 策略 ────────────────────────────────────────────────────────────────
|
|
133
|
+
* 用一次 JSON 往返把值变成纯数据(`JSON.parse(JSON.stringify(v))`):
|
|
134
|
+
* - 类实例 → 纯对象(原型丢失,但我们只读它的可枚举数据字段)
|
|
135
|
+
* - undefined 属性 → 被 JSON.stringify 丢掉(正是我们要的)
|
|
136
|
+
* - NaN / Infinity → **会被 stringify 变成 null**,所以先手动扫一遍转成 0
|
|
137
|
+
* - 循环引用 → stringify 抛错,被 catch 兜住
|
|
138
|
+
*
|
|
139
|
+
* 注意:这里**不**做字段级白名单(那是 views.js 的职责)。本函数只管
|
|
140
|
+
* "能不能安全出边界"这一件事,让每个工具不必各自记得处理。
|
|
141
|
+
*
|
|
142
|
+
* @param {unknown} v 任意工具返回值
|
|
143
|
+
* @returns {unknown} 纯 JSON 值;无法净化时返回一个最小安全对象
|
|
144
|
+
*/
|
|
145
|
+
function toPlainJson(v) {
|
|
146
|
+
try {
|
|
147
|
+
// 先把 NaN / Infinity / -0 归一(JSON.stringify 会把它们变成 null,
|
|
148
|
+
// 而 null 对 schema 声明为 number 的字段又是违规的 → 用 0 更安全)
|
|
149
|
+
const norm = (x, seen) => {
|
|
150
|
+
// ⚠ null 必须**原样保留**:DSH 的 walkJsonValue 明确接受 null
|
|
151
|
+
// (`if (current === null) { assign(...); continue }`)。
|
|
152
|
+
// 早先这里写的是 `if (x === null || x === undefined) return undefined`,
|
|
153
|
+
// 把 null 一起吞了 —— 会让「值为 null 的字段」静默消失。已由
|
|
154
|
+
// test/tool-output-plain.test.js 的「null 保持 null」用例锁住。
|
|
155
|
+
if (x === null) return null
|
|
156
|
+
if (x === undefined) return undefined
|
|
157
|
+
const t = typeof x
|
|
158
|
+
if (t === 'number') return Number.isFinite(x) && !Object.is(x, -0) ? x : 0
|
|
159
|
+
if (t === 'string' || t === 'boolean') return x
|
|
160
|
+
if (t === 'bigint') return String(x)
|
|
161
|
+
if (t === 'function' || t === 'symbol') return undefined
|
|
162
|
+
if (t !== 'object') return undefined
|
|
163
|
+
if (seen.has(x)) return undefined
|
|
164
|
+
seen.add(x)
|
|
165
|
+
try {
|
|
166
|
+
if (Array.isArray(x)) return x.map((item) => norm(item, seen)).filter((item) => item !== undefined)
|
|
167
|
+
const out = {}
|
|
168
|
+
for (const k of Object.keys(x)) {
|
|
169
|
+
const val = norm(x[k], seen)
|
|
170
|
+
if (val !== undefined) out[k] = val
|
|
171
|
+
}
|
|
172
|
+
return out
|
|
173
|
+
} finally {
|
|
174
|
+
seen.delete(x)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return norm(v, new Set())
|
|
178
|
+
} catch {
|
|
179
|
+
return {}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 渲染文本上限:工具结果进模型上下文,别把整份清单灌进去。 */
|
|
184
|
+
const RENDER_LIMIT = 6000
|
|
185
|
+
|
|
186
|
+
function text(value) {
|
|
187
|
+
let s
|
|
188
|
+
if (typeof value === 'string') s = value
|
|
189
|
+
else {
|
|
190
|
+
try {
|
|
191
|
+
s = JSON.stringify(value, null, 2)
|
|
192
|
+
} catch {
|
|
193
|
+
s = undefined
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// JSON.stringify(undefined) / 循环引用都返回 undefined:展示层退化成占位文本。
|
|
197
|
+
if (typeof s !== 'string') return '(no value)'
|
|
198
|
+
return s.length > RENDER_LIMIT ? `${s.slice(0, RENDER_LIMIT)}\n… (truncated)` : s
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 包一层 render。
|
|
203
|
+
*
|
|
204
|
+
* render 是"展示层",它绝不该把一次成功的工具调用变成失败:值形状意外
|
|
205
|
+
* (例如被裁剪、被旧版投影喂进来)时退回 JSON 文本,而不是抛出去。
|
|
206
|
+
*/
|
|
207
|
+
function renderText(fn) {
|
|
208
|
+
return (args, value) => {
|
|
209
|
+
try {
|
|
210
|
+
const out = fn(args, value)
|
|
211
|
+
return [{ type: 'text', text: typeof out === 'string' ? text(out) : text(out ?? value) }]
|
|
212
|
+
} catch {
|
|
213
|
+
return [{ type: 'text', text: text(value) }]
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 安全的数组读取:值缺失时给空数组,而不是 undefined.length。 */
|
|
219
|
+
function arr(v) {
|
|
220
|
+
return Array.isArray(v) ? v : []
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function num(v) {
|
|
224
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function human(bytes) {
|
|
228
|
+
if (!Number.isFinite(bytes)) return '?'
|
|
229
|
+
const units = ['B', 'KiB', 'MiB', 'GiB']
|
|
230
|
+
let n = bytes
|
|
231
|
+
let i = 0
|
|
232
|
+
while (n >= 1024 && i < units.length - 1) {
|
|
233
|
+
n /= 1024
|
|
234
|
+
i += 1
|
|
235
|
+
}
|
|
236
|
+
return `${i === 0 ? n : n.toFixed(1)} ${units[i]}`
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 从任务视图挑出可 pull 的成品路径。 */
|
|
240
|
+
export function finalPaths(task) {
|
|
241
|
+
const fromManifest = task?.manifest?.final
|
|
242
|
+
if (Array.isArray(fromManifest)) return fromManifest.map((i) => i.path)
|
|
243
|
+
const fromHandoff = task?.handoff?.artifacts?.final
|
|
244
|
+
if (Array.isArray(fromHandoff)) return fromHandoff.map((i) => i.path)
|
|
245
|
+
return []
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function buildManifestView(task) {
|
|
249
|
+
const m = task?.manifest ?? null
|
|
250
|
+
const a = task?.handoff?.artifacts ?? null
|
|
251
|
+
const counts = m?.counts ?? a?.counts ?? null
|
|
252
|
+
const bytesFinal = num(m?.bytesFinal) ?? num(a?.bytesFinal) ?? 0
|
|
253
|
+
const finalList = Array.isArray(m?.final) ? m.final : Array.isArray(a?.final) ? a.final : []
|
|
254
|
+
// ⚠ 声明为 string 的字段一律不能返回 null(DSH 会报 value is not lossless JSON)。
|
|
255
|
+
const str = (x) => (typeof x === 'string' ? x : '')
|
|
256
|
+
return {
|
|
257
|
+
taskId: str(task?.taskId),
|
|
258
|
+
status: str(task?.status) || 'unknown',
|
|
259
|
+
// counts 在 schema 里是 object,null 同样会触发序列化错误 → 用空对象兜底
|
|
260
|
+
counts: counts && typeof counts === 'object' ? counts : {},
|
|
261
|
+
bytesFinal,
|
|
262
|
+
final: finalList.map((i) => ({
|
|
263
|
+
path: str(i.path),
|
|
264
|
+
kind: str(i.kind) || 'file',
|
|
265
|
+
bytes: num(i.bytes) ?? 0,
|
|
266
|
+
sha256: str(i.sha256),
|
|
267
|
+
})),
|
|
268
|
+
intermediateCount: num(m?.intermediateCount) ?? num(a?.intermediateCount) ?? 0,
|
|
269
|
+
note:
|
|
270
|
+
(typeof m?.note === 'string' && m.note) ||
|
|
271
|
+
(typeof a?.note === 'string' && a.note) ||
|
|
272
|
+
(finalList.length === 0
|
|
273
|
+
? '还没有清单:任务未结束,或本次没有 role:final 产物。'
|
|
274
|
+
: '只有 role:final 可以 pull;intermediate 仅上报元数据,留在副机。'),
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 从 handoff 生成一段人/模型都能直接读的摘要文本。 */
|
|
279
|
+
export function handoffSummaryText(task) {
|
|
280
|
+
const h = task?.handoff
|
|
281
|
+
if (!h) {
|
|
282
|
+
return `任务 ${task?.taskId ?? '(unknown)'} 尚无交接卡(status=${task?.status ?? 'unknown'})。`
|
|
283
|
+
}
|
|
284
|
+
const lines = []
|
|
285
|
+
lines.push(`# 远程任务交接 ${h.taskId}`)
|
|
286
|
+
lines.push('')
|
|
287
|
+
lines.push(`- 节点:${h.nodeId}`)
|
|
288
|
+
lines.push(`- 状态:${h.status}${h.exit?.timedOut ? '(超时)' : ''}`)
|
|
289
|
+
lines.push(`- 模式:${h.mode} 运行器:${h.runner ?? 'unknown'}`)
|
|
290
|
+
if (typeof h.durationMs === 'number') lines.push(`- 耗时:${Math.round(h.durationMs / 1000)}s`)
|
|
291
|
+
lines.push(`- 工作区:${h.workspace}`)
|
|
292
|
+
lines.push('')
|
|
293
|
+
lines.push('## 摘要')
|
|
294
|
+
lines.push('')
|
|
295
|
+
lines.push(String(h.summary?.text ?? '(无摘要)'))
|
|
296
|
+
const changed = Array.isArray(h.changed) ? h.changed : []
|
|
297
|
+
if (changed.length > 0) {
|
|
298
|
+
lines.push('')
|
|
299
|
+
lines.push(`## 工作区变更(${changed.length} 条)`)
|
|
300
|
+
lines.push('')
|
|
301
|
+
for (const c of changed.slice(0, 40)) {
|
|
302
|
+
lines.push(`- ${c.op} ${c.path}${typeof c.bytes === 'number' ? ` (${human(c.bytes)})` : ''}`)
|
|
303
|
+
}
|
|
304
|
+
if (changed.length > 40) lines.push(`- … 另有 ${changed.length - 40} 条`)
|
|
305
|
+
}
|
|
306
|
+
// 任务变更 diff 视图(提案 #21):有 diff 就直接给文本,模型无需再拉文件。
|
|
307
|
+
const diffs = Array.isArray(h.diffs) ? h.diffs : []
|
|
308
|
+
if (diffs.length > 0) {
|
|
309
|
+
lines.push('')
|
|
310
|
+
lines.push(`## 变更 diff(${diffs.length} 个文件)`)
|
|
311
|
+
for (const d of diffs) {
|
|
312
|
+
lines.push('', `### ${d.path}${d.truncated ? '(截断)' : ''}`, '', '```diff', String(d.diff), '```')
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const artifacts = h.artifacts
|
|
316
|
+
if (artifacts) {
|
|
317
|
+
lines.push('')
|
|
318
|
+
lines.push(`## 产物清单(final ${artifacts.counts?.final ?? 0} / intermediate ${artifacts.intermediateCount ?? 0})`)
|
|
319
|
+
lines.push('')
|
|
320
|
+
for (const f of (artifacts.final ?? []).slice(0, 40)) {
|
|
321
|
+
lines.push(`- ${f.path} — ${f.kind}, ${human(f.bytes)}, sha256=${String(f.sha256 ?? 'n/a').slice(0, 12)}…`)
|
|
322
|
+
}
|
|
323
|
+
lines.push('')
|
|
324
|
+
lines.push(String(artifacts.note ?? ''))
|
|
325
|
+
if (typeof h.pullHint === 'string') lines.push('', `拉取提示:${h.pullHint}`)
|
|
326
|
+
}
|
|
327
|
+
const logs = h.logs?.tail
|
|
328
|
+
if (Array.isArray(logs) && logs.length > 0) {
|
|
329
|
+
lines.push('')
|
|
330
|
+
lines.push('## 日志尾巴')
|
|
331
|
+
lines.push('')
|
|
332
|
+
for (const l of logs.slice(-10)) lines.push(`> ${String(l).slice(0, 300)}`)
|
|
333
|
+
}
|
|
334
|
+
return lines.join('\n')
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* 注册七个工具。
|
|
339
|
+
* @returns {Array<() => void>} disposers
|
|
340
|
+
*/
|
|
341
|
+
export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults = {} }) {
|
|
342
|
+
const disposers = []
|
|
343
|
+
const defaultTimeoutMs = num(defaults.timeoutMs) ?? 60 * 60 * 1000
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* 注册一个工具,并给它的 `execute` **统一套上返回值净化**。
|
|
347
|
+
*
|
|
348
|
+
* 为什么在注册处收口而不是逐个工具处理:7 个工具的返回值形态各异
|
|
349
|
+
* (有的直接 return 对象字面量,有的 return 别的模块的投影结果),
|
|
350
|
+
* 逐个改容易漏。而 DSH 的 `walkJsonValue` 拒绝条件(类实例 / NaN /
|
|
351
|
+
* Infinity / -0 / undefined / 循环引用)对所有工具是一样的,
|
|
352
|
+
* 所以在唯一的注册入口包一层最省事、也最不容易漏。
|
|
353
|
+
*
|
|
354
|
+
* 见 `toPlainJson` 的说明:漏掉会导致「有副作用却报失败」——最坏的失败形态。
|
|
355
|
+
*/
|
|
356
|
+
const reg = (definition) => {
|
|
357
|
+
const original = definition?.execute
|
|
358
|
+
const schema = definition?.output?.schema
|
|
359
|
+
if (typeof original === 'function') {
|
|
360
|
+
definition = {
|
|
361
|
+
...definition,
|
|
362
|
+
async execute(...args) {
|
|
363
|
+
const value = await original.apply(this, args)
|
|
364
|
+
// ① 先按 schema 把「类型不匹配的 null」删掉 —— 见 dropNullForSchema 的说明。
|
|
365
|
+
// ② 再净化成纯 JSON 值(去类实例 / NaN / 循环引用等)。
|
|
366
|
+
// 顺序不能反:净化会丢弃 undefined,而 ① 正是把 null 转成 undefined。
|
|
367
|
+
return toPlainJson(dropNullForSchema(value, schema))
|
|
368
|
+
},
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
disposers.push(ctx.tools.register(definition))
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── remote_nodes ───────────────────────────────────────────────
|
|
375
|
+
reg({
|
|
376
|
+
name: 'remote_nodes',
|
|
377
|
+
description:
|
|
378
|
+
'列出所有已注册的远程副机节点(在线与离线都列出)。这是信息汇聚的入口:先看有哪些机器可用、各自的操作系统与允许的工作区根,再决定把任务派给谁。',
|
|
379
|
+
parameters: { type: 'object', properties: {}, required: [], additionalProperties: false },
|
|
380
|
+
output: {
|
|
381
|
+
schema: {
|
|
382
|
+
type: 'object',
|
|
383
|
+
properties: {
|
|
384
|
+
nodes: {
|
|
385
|
+
type: 'array',
|
|
386
|
+
items: {
|
|
387
|
+
type: 'object',
|
|
388
|
+
properties: {
|
|
389
|
+
nodeId: { type: 'string' },
|
|
390
|
+
os: { type: 'string' },
|
|
391
|
+
arch: { type: 'string' },
|
|
392
|
+
hostname: { type: 'string' },
|
|
393
|
+
online: { type: 'boolean' },
|
|
394
|
+
roots: { type: 'array', items: { type: 'string' } },
|
|
395
|
+
caps: { type: 'array', items: { type: 'string' } },
|
|
396
|
+
agentMode: { type: 'boolean' },
|
|
397
|
+
since: { type: 'number' },
|
|
398
|
+
lastSeen: { type: 'number' },
|
|
399
|
+
taskCount: { type: 'number' },
|
|
400
|
+
repos: { type: 'array', items: { type: 'string' } },
|
|
401
|
+
reposAt: { type: 'number' },
|
|
402
|
+
// resource 内层字段可能为 null(首次采样无 CPU 基线等)——
|
|
403
|
+
// 靠 stripNulls 按本 schema 剥掉 null 字段,这里只声明有值形态。
|
|
404
|
+
resource: {
|
|
405
|
+
type: 'object',
|
|
406
|
+
properties: {
|
|
407
|
+
cpuPct: { type: 'number' },
|
|
408
|
+
load1: { type: 'number' },
|
|
409
|
+
memUsedMb: { type: 'number' },
|
|
410
|
+
memTotalMb: { type: 'number' },
|
|
411
|
+
at: { type: 'number' },
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
required: ['nodeId', 'online'],
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
protocol: { type: 'number' },
|
|
419
|
+
},
|
|
420
|
+
required: ['nodes'],
|
|
421
|
+
},
|
|
422
|
+
render: renderText((_args, value) => {
|
|
423
|
+
const nodes = arr(value?.nodes)
|
|
424
|
+
if (nodes.length === 0) return '当前没有任何远程节点接入。'
|
|
425
|
+
const lines = [`远程节点(${nodes.filter((n) => n.online).length}/${nodes.length} 在线):`, '']
|
|
426
|
+
for (const n of nodes) {
|
|
427
|
+
lines.push(
|
|
428
|
+
`- ${n.nodeId} [${n.online ? 'online' : 'offline'}] ${n.os}/${n.arch} host=${n.hostname} agentMode=${n.agentMode} tasks=${n.taskCount}`,
|
|
429
|
+
)
|
|
430
|
+
if (n.roots?.length) lines.push(` roots: ${n.roots.join(', ')}`)
|
|
431
|
+
if (n.caps?.length) lines.push(` caps: ${n.caps.join(', ')}`)
|
|
432
|
+
if (Array.isArray(n.repos) && n.repos.length > 0) lines.push(` repos: ${n.repos.join(', ')}(remote_task 可用 repo:"名字" 按仓库路由)`)
|
|
433
|
+
if (n.resource) {
|
|
434
|
+
lines.push(` 资源: CPU ${n.resource.cpuPct ?? '—'}% · 内存 ${n.resource.memUsedMb ?? '—'}/${n.resource.memTotalMb ?? '—'}MB · load1 ${n.resource.load1 ?? '—'}`)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return lines.join('\n')
|
|
438
|
+
}),
|
|
439
|
+
},
|
|
440
|
+
async execute() {
|
|
441
|
+
return { protocol: PROTOCOL_VERSION, nodes: registry.list() }
|
|
442
|
+
},
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
// ── remote_task ────────────────────────────────────────────────
|
|
446
|
+
reg({
|
|
447
|
+
name: 'remote_task',
|
|
448
|
+
description:
|
|
449
|
+
'把一条任务派给远程副机执行。副机在自己的机器上本地跑(默认起一个独立 agent 会话,mode=exec 则直接跑一段命令),任务在副机持久运行、断连不终止。本工具只等到副机"接受任务"就返回,不等结果;之后用 remote_status / remote_handoff 跟进。',
|
|
450
|
+
parameters: {
|
|
451
|
+
type: 'object',
|
|
452
|
+
properties: {
|
|
453
|
+
node: { type: 'string', description: '目标节点 id(或主机名)。省略时自动选:多节点按在跑任务数/CPU/内存自动路由(agent 模式只选支持 agent 的节点)。' },
|
|
454
|
+
repo: { type: 'string', description: '按仓库路由(提案 #13):传仓库名、远端 url 片段或本机路径片段,任务派给持有该仓库的节点(多个时按负载自动选)。省略 workspace 时自动用仓库在本机的路径。node 显式指定时 repo 不改路由、只兜 workspace。' },
|
|
455
|
+
dryRun: { type: 'boolean', description: '只做派发预览(提案 #23):走与真实派发完全相同的路由与校验,返回"会派到哪个节点、哪个工作区、什么模式",不落库不派发。确认无误后去掉本参数正式派。' },
|
|
456
|
+
goal: { type: 'string', description: '任务目标。mode=agent 时是给副机 agent 的任务书;mode=exec 时是要执行的命令。' },
|
|
457
|
+
workspace: { type: 'string', description: '副机上的绝对工作区路径,必须落在该节点允许的 roots 之内。省略则用节点默认工作区。' },
|
|
458
|
+
mode: { type: 'string', enum: ['agent', 'exec'], description: 'agent=副机起独立 agent 会话(默认);exec=直接执行命令。' },
|
|
459
|
+
approval: { type: 'string', enum: ['auto', 'manual'], description: '两阶段审批(提案 #2):manual=hub 挂起,等面板批准后才派给副机,超时自动拒绝(fail-closed);auto=立即派发。省略时 exec 默认 manual、agent 默认 auto。' },
|
|
460
|
+
model: {
|
|
461
|
+
type: 'object',
|
|
462
|
+
description:
|
|
463
|
+
'副机执行本任务时使用的模型(可选,仅 mode=agent 生效)。只传**选择**、不传凭证 —— 副机用自己的 provider 配置跑;'
|
|
464
|
+
+ '指定的 provider/model 在副机不存在时任务会以明确错误失败。字段全部可选,缺的借副机默认补齐。'
|
|
465
|
+
+ '注意:headless 后端不支持指定模型,会以 modelUnsupported 失败。',
|
|
466
|
+
properties: {
|
|
467
|
+
provider: { type: 'string', description: 'provider 名(如 openai / deepseek)。省略则用副机默认 provider。' },
|
|
468
|
+
model: { type: 'string', description: '模型名(如 gpt-4o / deepseek-chat)。' },
|
|
469
|
+
reasoningEffort: { type: 'string', description: '推理力度(provider 支持时生效)。' },
|
|
470
|
+
},
|
|
471
|
+
additionalProperties: false,
|
|
472
|
+
},
|
|
473
|
+
outputs: { type: 'array', items: { type: 'string' }, description: '期望产出物的 glob(相对工作区),用于产物清单筛选。' },
|
|
474
|
+
timeoutMs: { type: 'number', description: `副机侧任务超时(毫秒),默认 ${defaultTimeoutMs}。` },
|
|
475
|
+
denyPaths: { type: 'array', items: { type: 'string' }, description: '副机侧额外禁止访问的路径(绝对路径)。' },
|
|
476
|
+
artifactRules: {
|
|
477
|
+
type: 'object',
|
|
478
|
+
description:
|
|
479
|
+
'覆盖内置的产物角色判定。final=强制判为成品(可 pull);intermediate=强制判为中间产物(不过河)。'
|
|
480
|
+
+ '用于内置表不适配的仓库,例如把 build/ 当源码目录时传 { intermediate: ["build/**"] }。',
|
|
481
|
+
properties: {
|
|
482
|
+
final: { type: 'array', items: { type: 'string' }, description: 'glob 列表,命中即判为 role:final。' },
|
|
483
|
+
intermediate: { type: 'array', items: { type: 'string' }, description: 'glob 列表,命中即判为 role:intermediate(优先于 final)。' },
|
|
484
|
+
},
|
|
485
|
+
additionalProperties: false,
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
required: ['goal'],
|
|
489
|
+
additionalProperties: false,
|
|
490
|
+
},
|
|
491
|
+
output: {
|
|
492
|
+
schema: {
|
|
493
|
+
type: 'object',
|
|
494
|
+
properties: {
|
|
495
|
+
taskId: { type: 'string' },
|
|
496
|
+
node: { type: 'string' },
|
|
497
|
+
accepted: { type: 'boolean' },
|
|
498
|
+
workspace: { type: 'string' },
|
|
499
|
+
runner: { type: 'string' },
|
|
500
|
+
status: { type: 'string' },
|
|
501
|
+
duplicate: { type: 'boolean' },
|
|
502
|
+
// dry-run(提案 #23)与审批(提案 #2)扩展字段
|
|
503
|
+
dryRun: { type: 'boolean' },
|
|
504
|
+
mode: { type: 'string' },
|
|
505
|
+
approval: { type: 'string' },
|
|
506
|
+
repo: { type: 'string' },
|
|
507
|
+
error: { type: 'object', properties: { code: { type: 'string' }, message: { type: 'string' } } },
|
|
508
|
+
hint: { type: 'string' },
|
|
509
|
+
},
|
|
510
|
+
required: ['taskId', 'node', 'accepted'],
|
|
511
|
+
},
|
|
512
|
+
render: renderText((_args, value) => {
|
|
513
|
+
if (!value) return 'remote_task: 无返回'
|
|
514
|
+
if (value.dryRun === true) {
|
|
515
|
+
return [
|
|
516
|
+
'[dry-run 预览] 未派发、未落库 —— 确认无误后去掉 dryRun 正式派发',
|
|
517
|
+
`node: ${value.node}${value.repo ? `(repo 命中:${value.repo})` : ''}`,
|
|
518
|
+
`mode: ${value.mode ?? 'agent'}${value.approval === 'manual' ? '(将挂起等待审批)' : ''}`,
|
|
519
|
+
`workspace: ${value.workspace ?? '(节点默认)'}`,
|
|
520
|
+
value.hint ?? '',
|
|
521
|
+
].filter((l) => l !== '').join('\n')
|
|
522
|
+
}
|
|
523
|
+
if (value.status === 'pending_approval') {
|
|
524
|
+
return [
|
|
525
|
+
`已创建 ${value.taskId} → ${value.node}(等待审批,副机尚未收到)`,
|
|
526
|
+
`workspace: ${value.workspace ?? '(节点默认)'}`,
|
|
527
|
+
value.hint ?? '',
|
|
528
|
+
].filter((l) => l !== '').join('\n')
|
|
529
|
+
}
|
|
530
|
+
const head = value.accepted
|
|
531
|
+
? `已派单 ${value.taskId} → ${value.node}(status=${value.status})`
|
|
532
|
+
: `派单未被接受 ${value.taskId} → ${value.node}:${value.error?.code ?? 'unknown'} ${value.error?.message ?? ''}`
|
|
533
|
+
return [head, `workspace: ${value.workspace ?? '(节点默认)'}`, `runner: ${value.runner ?? '(未知)'}`, value.hint ?? '']
|
|
534
|
+
.filter((l) => l !== '')
|
|
535
|
+
.join('\n')
|
|
536
|
+
}),
|
|
537
|
+
},
|
|
538
|
+
async execute(args, exec) {
|
|
539
|
+
const routeMode = args?.mode === 'exec' ? 'exec' : 'agent'
|
|
540
|
+
// 提案 #13:repo 参数路由 —— 在持有该仓库的在线节点里做负载感知自动选。
|
|
541
|
+
// node 显式给出时 repo 不改路由(用户点名了机器就是点名了),只在
|
|
542
|
+
// workspace 省略时借该节点上的仓库路径兜底。
|
|
543
|
+
const repoSel = typeof args?.repo === 'string' ? args.repo.trim() : ''
|
|
544
|
+
const explicitNode = typeof args?.node === 'string' && args.node.trim() !== ''
|
|
545
|
+
let nodeId
|
|
546
|
+
let repoHit = null
|
|
547
|
+
if (repoSel !== '' && !explicitNode) {
|
|
548
|
+
const hit = registry.pickNodeByRepo(repoSel, { mode: routeMode })
|
|
549
|
+
if (!hit) {
|
|
550
|
+
const known = registry.knownRepoNames()
|
|
551
|
+
throw new RemoteAgentError(
|
|
552
|
+
`没有在线节点持有仓库 "${repoSel}"`
|
|
553
|
+
+ (known.length > 0
|
|
554
|
+
? `;当前已上报的仓库:${known.slice(0, 20).join('、')}`
|
|
555
|
+
: ';尚无节点上报仓库清单(副机需升级到支持仓库上报的版本,接入后约 10s 内上报)'),
|
|
556
|
+
ERROR_CODE.INTERNAL,
|
|
557
|
+
)
|
|
558
|
+
}
|
|
559
|
+
nodeId = hit.nodeId
|
|
560
|
+
repoHit = hit.repo
|
|
561
|
+
} else {
|
|
562
|
+
// 提案 #14:mode 传入自动路由——agent 模式只选支持 agent 后端的节点
|
|
563
|
+
nodeId = resolveNodeId(registry, args?.node, { mode: routeMode })
|
|
564
|
+
if (repoSel !== '') repoHit = registry.findRepoOn(nodeId, repoSel)
|
|
565
|
+
}
|
|
566
|
+
const taskId = newTaskId()
|
|
567
|
+
const hasWorkspace = typeof args?.workspace === 'string' && args.workspace.trim() !== ''
|
|
568
|
+
const v = validateTaskStart({
|
|
569
|
+
taskId,
|
|
570
|
+
goal: String(args?.goal ?? ''),
|
|
571
|
+
workspace: hasWorkspace ? args.workspace : repoHit?.path,
|
|
572
|
+
mode: args?.mode === TASK_MODE.EXEC ? TASK_MODE.EXEC : TASK_MODE.AGENT,
|
|
573
|
+
// 两阶段审批(提案 #2):缺省语义在 validateTaskStart 里(exec→manual、
|
|
574
|
+
// agent→auto),这里只透传显式选择。
|
|
575
|
+
approval: args?.approval,
|
|
576
|
+
// 模型选择:协议层 normalizeModelSelection 会把字符串/对象归一、
|
|
577
|
+
// 丢掉类型不对的输入(见该函数说明)。exec 模式忽略之。
|
|
578
|
+
model: args?.model,
|
|
579
|
+
outputs: args?.outputs,
|
|
580
|
+
// artifactRules 从工具参数透传到任务规格(F3-1 修复:此前该字段
|
|
581
|
+
// 只有消费者没有生产者,用户无法覆盖内置的产物角色判定)
|
|
582
|
+
artifactRules: args?.artifactRules,
|
|
583
|
+
constraints: {
|
|
584
|
+
timeoutMs: num(args?.timeoutMs) ?? defaultTimeoutMs,
|
|
585
|
+
denyPaths: Array.isArray(args?.denyPaths) ? args.denyPaths : [],
|
|
586
|
+
},
|
|
587
|
+
origin: 'hub',
|
|
588
|
+
})
|
|
589
|
+
if (!v.ok) throw new RemoteAgentError(`invalid task: ${v.error}`, ERROR_CODE.INTERNAL)
|
|
590
|
+
|
|
591
|
+
const str = (x) => (typeof x === 'string' ? x : '')
|
|
592
|
+
// 提案 #23:dry-run —— 只算"会怎么派",不落库不派帧、不烧 taskId。
|
|
593
|
+
// 路由与校验和真实派发走同一条代码路径(同一处 repo 解析、同一个
|
|
594
|
+
// validateTaskStart):预览说"可以"就真的可以派,预览报错就是真会报错。
|
|
595
|
+
if (args?.dryRun === true) {
|
|
596
|
+
// workspace 省略时副机会用 defaultWorkspace(= hello.roots[0],见
|
|
597
|
+
// node/index.js)—— 预览必须镜像同一回填,否则预览和实派不一致。
|
|
598
|
+
const rec0 = registry.getNode(nodeId)
|
|
599
|
+
return {
|
|
600
|
+
taskId: '',
|
|
601
|
+
node: nodeId,
|
|
602
|
+
accepted: false,
|
|
603
|
+
dryRun: true,
|
|
604
|
+
mode: v.spec.mode,
|
|
605
|
+
approval: v.spec.approval,
|
|
606
|
+
workspace: str(v.spec.workspace) || str(rec0?.hello?.roots?.[0]),
|
|
607
|
+
repo: str(repoHit?.name),
|
|
608
|
+
hint: '预览即真实(与正式派发同一套路由+校验);确认无误后去掉 dryRun 正式派发。',
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
registry.createTask({ nodeId, spec: v.spec })
|
|
613
|
+
|
|
614
|
+
// 两阶段审批(提案 #2):manual 任务挂起在 hub 等面板批准 —— 不等
|
|
615
|
+
// awaitAccepted(副机此刻根本没收到)。立即返回挂起回执,调用方用
|
|
616
|
+
// remote_status 跟进;批准/拒绝都在面板完成。
|
|
617
|
+
if (v.spec.approval === 'manual') {
|
|
618
|
+
const rec0 = registry.getNode(nodeId)
|
|
619
|
+
return {
|
|
620
|
+
taskId,
|
|
621
|
+
node: nodeId,
|
|
622
|
+
accepted: false,
|
|
623
|
+
status: 'pending_approval',
|
|
624
|
+
mode: v.spec.mode,
|
|
625
|
+
approval: 'manual',
|
|
626
|
+
workspace: str(v.spec.workspace) || str(rec0?.hello?.roots?.[0]),
|
|
627
|
+
hint: '任务已挂起等待审批(面板「远程同事」→ 待审批卡)。批准后副机才会收到;超时未批会自动拒绝。',
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const accepted = await registry.awaitAccepted(taskId, { signal: exec?.signal })
|
|
632
|
+
const rec = registry.getNode(nodeId)
|
|
633
|
+
// ⚠ 返回值每个字段都必须与 output.schema 的类型**严格一致**。
|
|
634
|
+
//
|
|
635
|
+
// 早先这里是 `accepted.workspace ?? rec?.hello?.roots?.[0] ?? null`,而 schema
|
|
636
|
+
// 声明 `workspace: { type: 'string' }` —— 返回 null 时 DSH 直接报
|
|
637
|
+
// `value is not lossless JSON`。**任务其实已成功派出**,但调用方只看到序列化
|
|
638
|
+
// 错误,无从判断任务到底派没派出去(实测踩到,还误以为工具坏了)。
|
|
639
|
+
// 所以一律用空字符串兜底;`error` 只在存在时才给。
|
|
640
|
+
return {
|
|
641
|
+
taskId,
|
|
642
|
+
node: nodeId,
|
|
643
|
+
accepted: accepted.accepted === true,
|
|
644
|
+
workspace: str(accepted.workspace) || str(rec?.hello?.roots?.[0]),
|
|
645
|
+
runner: str(accepted.runner),
|
|
646
|
+
status: str(accepted.status),
|
|
647
|
+
duplicate: accepted.duplicate === true,
|
|
648
|
+
...(accepted.error && typeof accepted.error === 'object'
|
|
649
|
+
? { error: { code: str(accepted.error.code), message: str(accepted.error.message) } }
|
|
650
|
+
: {}),
|
|
651
|
+
hint:
|
|
652
|
+
accepted.accepted === true
|
|
653
|
+
? '用 remote_status 跟进进度;结束后用 remote_handoff 取交接卡。'
|
|
654
|
+
: '副机未接受这条任务,检查 workspace 是否在节点允许的 roots 内。',
|
|
655
|
+
}
|
|
656
|
+
},
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
// ── remote_status ──────────────────────────────────────────────
|
|
660
|
+
reg({
|
|
661
|
+
name: 'remote_status',
|
|
662
|
+
description: '查询一条远程任务的当前视图:状态、进度尾巴、日志尾巴、退出码、工作区变更、摘要。任务状态的真身在副机,这里读的是 hub 侧投影(断连不会丢,重连后由副机 task/sync 重建)。',
|
|
663
|
+
parameters: {
|
|
664
|
+
type: 'object',
|
|
665
|
+
properties: { taskId: { type: 'string', description: 'remote_task 返回的 taskId。' } },
|
|
666
|
+
required: ['taskId'],
|
|
667
|
+
additionalProperties: false,
|
|
668
|
+
},
|
|
669
|
+
output: {
|
|
670
|
+
schema: {
|
|
671
|
+
type: 'object',
|
|
672
|
+
properties: {
|
|
673
|
+
taskId: { type: 'string' },
|
|
674
|
+
nodeId: { type: 'string' },
|
|
675
|
+
nodeOnline: { type: 'boolean' },
|
|
676
|
+
status: { type: 'string' },
|
|
677
|
+
mode: { type: 'string' },
|
|
678
|
+
goal: { type: 'string' },
|
|
679
|
+
workspace: { type: 'string' },
|
|
680
|
+
accepted: { type: 'boolean' },
|
|
681
|
+
createdAt: { type: 'number' },
|
|
682
|
+
updatedAt: { type: 'number' },
|
|
683
|
+
startedAt: { type: 'number' },
|
|
684
|
+
endedAt: { type: 'number' },
|
|
685
|
+
durationMs: { type: 'number' },
|
|
686
|
+
exit: { type: 'object' },
|
|
687
|
+
changed: { type: 'array', items: { type: 'object' } },
|
|
688
|
+
summary: { type: 'object' },
|
|
689
|
+
error: { type: 'object' },
|
|
690
|
+
manifestCounts: { type: 'object' },
|
|
691
|
+
progressTail: { type: 'array', items: { type: 'object' } },
|
|
692
|
+
logTail: { type: 'array', items: { type: 'string' } },
|
|
693
|
+
handoffAvailable: { type: 'boolean' },
|
|
694
|
+
},
|
|
695
|
+
required: ['taskId', 'status'],
|
|
696
|
+
},
|
|
697
|
+
render: renderText((_args, value) => {
|
|
698
|
+
const lines = [
|
|
699
|
+
`任务 ${value.taskId} @ ${value.nodeId} [${value.status}]${value.nodeOnline ? '' : ' (节点离线)'}`,
|
|
700
|
+
`mode=${value.mode ?? '?'} accepted=${value.accepted} 耗时=${value.durationMs ?? '?'}ms`,
|
|
701
|
+
`goal: ${String(value.goal ?? '').slice(0, 400)}`,
|
|
702
|
+
]
|
|
703
|
+
if (value.error) lines.push(`error: ${value.error.code}: ${value.error.message}`)
|
|
704
|
+
if (value.exit) lines.push(`exit: code=${value.exit.code} timedOut=${value.exit.timedOut}`)
|
|
705
|
+
const progress = arr(value.progressTail)
|
|
706
|
+
if (progress.length) {
|
|
707
|
+
lines.push('', '进度尾巴:')
|
|
708
|
+
for (const p of progress.slice(-10)) lines.push(`- ${p.phase}${p.detail ? `: ${String(p.detail).slice(0, 200)}` : ''}`)
|
|
709
|
+
}
|
|
710
|
+
const logs = arr(value.logTail)
|
|
711
|
+
if (logs.length) {
|
|
712
|
+
lines.push('', '日志尾巴:')
|
|
713
|
+
for (const l of logs.slice(-15)) lines.push(`> ${String(l).slice(0, 300)}`)
|
|
714
|
+
}
|
|
715
|
+
if (value.handoffAvailable) lines.push('', '交接卡已就绪:用 remote_handoff 取完整卡片。')
|
|
716
|
+
return lines.join('\n')
|
|
717
|
+
}),
|
|
718
|
+
},
|
|
719
|
+
async execute(args) {
|
|
720
|
+
const t = requireTask(registry, args?.taskId)
|
|
721
|
+
const v = registry.taskView(t)
|
|
722
|
+
// ⚠ 共享投影(views.js 的 exitView / errorView)对"没有值"的输入返回 null,
|
|
723
|
+
// 而本工具的 schema 声明 `exit`/`error` 是 object —— null 会让 DSH 的 schema
|
|
724
|
+
// 校验报 `"value.error" must be an object`,整个调用失败(实测踩到:
|
|
725
|
+
// remote_status 查一个刚创建、还没开始执行的任务)。
|
|
726
|
+
//
|
|
727
|
+
// 为什么不直接改 views.js:那两个函数是**共享层**,UI 路由也在用,UI 侧
|
|
728
|
+
// 接受 null(只是不渲染该块)。改共享层会动到 UI 契约。
|
|
729
|
+
// 所以在工具层收口 —— 把 null 换成**省略字段**(两者都不在 required 里)。
|
|
730
|
+
const out = { ...v }
|
|
731
|
+
if (!(out.exit && typeof out.exit === 'object')) delete out.exit
|
|
732
|
+
if (!(out.error && typeof out.error === 'object')) delete out.error
|
|
733
|
+
if (!(out.manifestCounts && typeof out.manifestCounts === 'object')) out.manifestCounts = {}
|
|
734
|
+
return out
|
|
735
|
+
},
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
// ── remote_handoff ─────────────────────────────────────────────
|
|
739
|
+
reg({
|
|
740
|
+
name: 'remote_handoff',
|
|
741
|
+
description: '取一条远程任务的完整交接卡:状态、退出码、摘要文本、工作区变更、变更 diff(提案 #21,文本文件的 unified diff)、产物清单、日志尾巴、拉取提示。这张卡的设计目标是让主电脑在不看副机任何原始日志的情况下做决策。',
|
|
742
|
+
parameters: {
|
|
743
|
+
type: 'object',
|
|
744
|
+
properties: { taskId: { type: 'string', description: 'remote_task 返回的 taskId。' } },
|
|
745
|
+
required: ['taskId'],
|
|
746
|
+
additionalProperties: false,
|
|
747
|
+
},
|
|
748
|
+
output: {
|
|
749
|
+
schema: {
|
|
750
|
+
type: 'object',
|
|
751
|
+
properties: {
|
|
752
|
+
taskId: { type: 'string' },
|
|
753
|
+
available: { type: 'boolean' },
|
|
754
|
+
status: { type: 'string' },
|
|
755
|
+
handoff: { type: 'object' },
|
|
756
|
+
note: { type: 'string' },
|
|
757
|
+
},
|
|
758
|
+
required: ['taskId', 'available'],
|
|
759
|
+
},
|
|
760
|
+
render: renderText((_args, value) => (value?.handoff ? handoffSummaryText({
|
|
761
|
+
taskId: value.taskId,
|
|
762
|
+
// handoffView 刻意不含 workspace(防绝对路径回显),这里补占位,
|
|
763
|
+
// 否则文本里会打出 "工作区:undefined"(与 subagent-provider 同一处理)。
|
|
764
|
+
handoff: { ...value.handoff, workspace: value.handoff.workspace ?? '(未回传)' },
|
|
765
|
+
}) : value?.note ?? '')),
|
|
766
|
+
},
|
|
767
|
+
async execute(args) {
|
|
768
|
+
const t = requireTask(registry, args?.taskId)
|
|
769
|
+
if (!t.handoff) {
|
|
770
|
+
// ⚠ 不要写 `handoff: null` —— schema 声明 `handoff: { type: 'object' }`,
|
|
771
|
+
// null 会触发 `value is not lossless JSON`(与 remote_task 的 workspace 同一类
|
|
772
|
+
// 问题)。handoff 不在 required 里,所以**省略该字段**即可。
|
|
773
|
+
return {
|
|
774
|
+
taskId: t.taskId,
|
|
775
|
+
available: false,
|
|
776
|
+
status: t.status,
|
|
777
|
+
note: TERMINAL_STATUS.has(t.status)
|
|
778
|
+
? '任务已结束但副机尚未上报交接卡(可能在上报途中)。稍后用 remote_status 复查。'
|
|
779
|
+
: `任务还在 ${t.status},交接卡要等任务结束后才有。用 remote_status 跟进。`,
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
// handoff 是**副机上报**的对象,字段集不受本文件控制(副机版本升级、
|
|
783
|
+
// 或将来有人往里加诊断字段)。必须经 handoffView 逐字段投影后再交给模型 ——
|
|
784
|
+
// 工具返回值会进入会话日志,被后续每个请求带上,比 UI 那条路径更该加固。
|
|
785
|
+
// (此前这里是裸直出 `handoff: t.handoff`,是"加固只做了一半"的实例。)
|
|
786
|
+
return { taskId: t.taskId, available: true, status: t.status, handoff: handoffView(t.handoff), note: '' }
|
|
787
|
+
},
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
// ── remote_manifest ────────────────────────────────────────────
|
|
791
|
+
reg({
|
|
792
|
+
name: 'remote_manifest',
|
|
793
|
+
description: '取一条远程任务的产物清单(不含文件字节)。清单先于字节:只有 role:final 的条目可以 pull;intermediate(日志、缓存、中间构建文件)只在副机留存,这里只给计数。',
|
|
794
|
+
parameters: {
|
|
795
|
+
type: 'object',
|
|
796
|
+
properties: { taskId: { type: 'string', description: 'remote_task 返回的 taskId。' } },
|
|
797
|
+
required: ['taskId'],
|
|
798
|
+
additionalProperties: false,
|
|
799
|
+
},
|
|
800
|
+
output: {
|
|
801
|
+
schema: {
|
|
802
|
+
type: 'object',
|
|
803
|
+
properties: {
|
|
804
|
+
taskId: { type: 'string' },
|
|
805
|
+
status: { type: 'string' },
|
|
806
|
+
counts: { type: 'object' },
|
|
807
|
+
bytesFinal: { type: 'number' },
|
|
808
|
+
final: {
|
|
809
|
+
type: 'array',
|
|
810
|
+
items: {
|
|
811
|
+
type: 'object',
|
|
812
|
+
properties: {
|
|
813
|
+
path: { type: 'string' },
|
|
814
|
+
kind: { type: 'string' },
|
|
815
|
+
bytes: { type: 'number' },
|
|
816
|
+
sha256: { type: 'string' },
|
|
817
|
+
},
|
|
818
|
+
required: ['path'],
|
|
819
|
+
},
|
|
820
|
+
},
|
|
821
|
+
intermediateCount: { type: 'number' },
|
|
822
|
+
note: { type: 'string' },
|
|
823
|
+
},
|
|
824
|
+
required: ['final'],
|
|
825
|
+
},
|
|
826
|
+
render: renderText((_args, value) => {
|
|
827
|
+
const finalList = arr(value.final)
|
|
828
|
+
const lines = [`产物清单 ${value.taskId} [${value.status}] final=${finalList.length} intermediate=${value.intermediateCount} 共 ${human(value.bytesFinal)}`]
|
|
829
|
+
for (const f of finalList.slice(0, 60)) {
|
|
830
|
+
lines.push(`- ${f.path} — ${f.kind}, ${human(f.bytes)}, sha256=${String(f.sha256 ?? 'n/a').slice(0, 12)}`)
|
|
831
|
+
}
|
|
832
|
+
lines.push('', value.note ?? '')
|
|
833
|
+
return lines.join('\n')
|
|
834
|
+
}),
|
|
835
|
+
},
|
|
836
|
+
async execute(args) {
|
|
837
|
+
const t = requireTask(registry, args?.taskId)
|
|
838
|
+
return buildManifestView(t)
|
|
839
|
+
},
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
// ── remote_pull ────────────────────────────────────────────────
|
|
843
|
+
reg({
|
|
844
|
+
name: 'remote_pull',
|
|
845
|
+
description:
|
|
846
|
+
'把副机上的成品拉到本机。只有清单里 role:final 的文件可以拉;传输按块进行,落地前校验 sha256,不一致就丢弃并报 hash-mismatch。dest 是本机目标目录,文件名取产物路径的最后一段。',
|
|
847
|
+
parameters: {
|
|
848
|
+
type: 'object',
|
|
849
|
+
properties: {
|
|
850
|
+
taskId: { type: 'string', description: 'remote_task 返回的 taskId。' },
|
|
851
|
+
paths: { type: 'array', items: { type: 'string' }, description: '清单内的相对路径(可多个)。' },
|
|
852
|
+
dest: { type: 'string', description: '本机目标目录(绝对路径)。省略则落到系统临时目录下的 dsh-remote-agent/<taskId>。' },
|
|
853
|
+
},
|
|
854
|
+
required: ['taskId', 'paths'],
|
|
855
|
+
additionalProperties: false,
|
|
856
|
+
},
|
|
857
|
+
output: {
|
|
858
|
+
schema: {
|
|
859
|
+
type: 'object',
|
|
860
|
+
properties: {
|
|
861
|
+
taskId: { type: 'string' },
|
|
862
|
+
dest: { type: 'string' },
|
|
863
|
+
pulled: {
|
|
864
|
+
type: 'array',
|
|
865
|
+
items: {
|
|
866
|
+
type: 'object',
|
|
867
|
+
properties: {
|
|
868
|
+
path: { type: 'string' },
|
|
869
|
+
bytes: { type: 'number' },
|
|
870
|
+
sha256: { type: 'string' },
|
|
871
|
+
dest: { type: 'string' },
|
|
872
|
+
},
|
|
873
|
+
required: ['path', 'dest'],
|
|
874
|
+
},
|
|
875
|
+
},
|
|
876
|
+
skipped: {
|
|
877
|
+
type: 'array',
|
|
878
|
+
items: {
|
|
879
|
+
type: 'object',
|
|
880
|
+
properties: { path: { type: 'string' }, reason: { type: 'string' } },
|
|
881
|
+
required: ['path', 'reason'],
|
|
882
|
+
},
|
|
883
|
+
},
|
|
884
|
+
},
|
|
885
|
+
required: ['pulled', 'skipped'],
|
|
886
|
+
},
|
|
887
|
+
render: renderText((_args, value) => {
|
|
888
|
+
const pulled = arr(value.pulled)
|
|
889
|
+
const skipped = arr(value.skipped)
|
|
890
|
+
const lines = [`拉取完成:成功 ${pulled.length},跳过 ${skipped.length}(目标目录 ${value.dest})`]
|
|
891
|
+
for (const p of pulled) lines.push(`- OK ${p.path} → ${p.dest} (${human(p.bytes)})`)
|
|
892
|
+
for (const s of skipped) lines.push(`- SKIP ${s.path}: ${s.reason}`)
|
|
893
|
+
return lines.join('\n')
|
|
894
|
+
}),
|
|
895
|
+
},
|
|
896
|
+
async execute(args, exec) {
|
|
897
|
+
const t = requireTask(registry, args?.taskId)
|
|
898
|
+
const paths = Array.isArray(args?.paths) ? args.paths.filter((p) => typeof p === 'string' && p !== '') : []
|
|
899
|
+
if (paths.length === 0) throw new RemoteAgentError('paths must contain at least one entry', ERROR_CODE.INTERNAL)
|
|
900
|
+
const destDir = typeof args?.dest === 'string' && args.dest !== ''
|
|
901
|
+
? path.resolve(args.dest)
|
|
902
|
+
: path.join(os.tmpdir(), 'dsh-remote-agent', t.taskId)
|
|
903
|
+
|
|
904
|
+
const pulled = []
|
|
905
|
+
const skipped = []
|
|
906
|
+
for (const relPath of paths) {
|
|
907
|
+
try {
|
|
908
|
+
const out = await pulls.pull({
|
|
909
|
+
nodeId: t.nodeId,
|
|
910
|
+
taskId: t.taskId,
|
|
911
|
+
relPath,
|
|
912
|
+
dest: destDir,
|
|
913
|
+
manifest: t.manifest,
|
|
914
|
+
signal: exec?.signal,
|
|
915
|
+
})
|
|
916
|
+
pulled.push({ path: out.path, bytes: out.bytes, sha256: out.sha256, dest: out.dest })
|
|
917
|
+
} catch (err) {
|
|
918
|
+
skipped.push({ path: relPath, reason: `${err?.code ?? 'error'}: ${String(err?.message ?? err)}` })
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
return { taskId: t.taskId, dest: destDir, pulled, skipped }
|
|
922
|
+
},
|
|
923
|
+
})
|
|
924
|
+
|
|
925
|
+
// ── remote_purge ────────────────────────────────────────────────
|
|
926
|
+
reg({
|
|
927
|
+
name: 'remote_purge',
|
|
928
|
+
description:
|
|
929
|
+
'清理 hub 侧的任务视图(只清视图,不碰副机的任务状态)。'
|
|
930
|
+
+ '用途:任务派多了之后 hub 视图会累积(副机的任务记录默认保留 7 天),'
|
|
931
|
+
+ '累积到上限就会拒绝新任务 —— 这个工具是那种情况的逃生舱。'
|
|
932
|
+
+ '默认只清已结束的任务;在跑的任务保留(除非显式 includeActive)。',
|
|
933
|
+
parameters: {
|
|
934
|
+
type: 'object',
|
|
935
|
+
properties: {
|
|
936
|
+
node: { type: 'string', description: '只清这个节点(省略则清全部节点的视图)。' },
|
|
937
|
+
includeActive: {
|
|
938
|
+
type: 'boolean',
|
|
939
|
+
description: '是否连在跑的任务也清(默认 false)。清掉后 remote_status 查不到它,但副机上它仍在继续跑。',
|
|
940
|
+
},
|
|
941
|
+
},
|
|
942
|
+
required: [],
|
|
943
|
+
additionalProperties: false,
|
|
944
|
+
},
|
|
945
|
+
output: {
|
|
946
|
+
schema: {
|
|
947
|
+
type: 'object',
|
|
948
|
+
properties: {
|
|
949
|
+
removed: { type: 'number' },
|
|
950
|
+
kept: { type: 'number' },
|
|
951
|
+
note: { type: 'string' },
|
|
952
|
+
},
|
|
953
|
+
required: ['removed', 'kept'],
|
|
954
|
+
},
|
|
955
|
+
render: renderText((_args, value) =>
|
|
956
|
+
`已清理 ${value.removed} 条任务视图${value.kept > 0 ? `,保留 ${value.kept} 条在跑的` : ''}。${value.note ? `\n${value.note}` : ''}`),
|
|
957
|
+
},
|
|
958
|
+
execute(args) {
|
|
959
|
+
// 显式指定 node 时先校验它存在 —— 否则会静默清 0 条,用户以为成功了。
|
|
960
|
+
let nodeId
|
|
961
|
+
if (typeof args?.node === 'string' && args.node.trim() !== '') {
|
|
962
|
+
const rec = registry.pickNode(args.node)
|
|
963
|
+
if (!rec) throw new RemoteAgentError(`unknown node: ${args.node}`, ERROR_CODE.TASK_UNKNOWN)
|
|
964
|
+
nodeId = rec.nodeId
|
|
965
|
+
}
|
|
966
|
+
const { removed, kept } = registry.purgeTasks({
|
|
967
|
+
nodeId,
|
|
968
|
+
includeActive: args?.includeActive === true,
|
|
969
|
+
})
|
|
970
|
+
return {
|
|
971
|
+
removed,
|
|
972
|
+
kept,
|
|
973
|
+
note:
|
|
974
|
+
removed === 0
|
|
975
|
+
? '没有可清理的终态任务视图。若仍被"already has N active tasks"挡住,说明在跑的任务确实到了上限。'
|
|
976
|
+
: '副机仍保留自己的任务记录(它是权威)。它下次上报 sync 时,未完成的任务会重新出现在这里。',
|
|
977
|
+
}
|
|
978
|
+
},
|
|
979
|
+
})
|
|
980
|
+
|
|
981
|
+
// ── remote_cancel ──────────────────────────────────────────────
|
|
982
|
+
reg({
|
|
983
|
+
name: 'remote_cancel',
|
|
984
|
+
description: '请求副机中断一条正在跑的任务。请求是尽力而为的:副机会打断 agent 或杀掉进程树,并把状态落成 aborted。已在途的产物拉取会一并取消并清理临时文件。',
|
|
985
|
+
parameters: {
|
|
986
|
+
type: 'object',
|
|
987
|
+
properties: { taskId: { type: 'string', description: 'remote_task 返回的 taskId。' } },
|
|
988
|
+
required: ['taskId'],
|
|
989
|
+
additionalProperties: false,
|
|
990
|
+
},
|
|
991
|
+
output: {
|
|
992
|
+
schema: {
|
|
993
|
+
type: 'object',
|
|
994
|
+
properties: {
|
|
995
|
+
taskId: { type: 'string' },
|
|
996
|
+
requested: { type: 'boolean' },
|
|
997
|
+
status: { type: 'string' },
|
|
998
|
+
note: { type: 'string' },
|
|
999
|
+
},
|
|
1000
|
+
required: ['taskId', 'requested'],
|
|
1001
|
+
},
|
|
1002
|
+
render: renderText((_args, value) => `${value.requested ? '已发送取消请求' : '取消请求未能送达'} ${value.taskId}(当前 status=${value.status})${value.note ? `\n${value.note}` : ''}`),
|
|
1003
|
+
},
|
|
1004
|
+
async execute(args) {
|
|
1005
|
+
const t = requireTask(registry, args?.taskId)
|
|
1006
|
+
pulls.cancelTask(t.taskId)
|
|
1007
|
+
const requested = registry.cancelTask(t.taskId)
|
|
1008
|
+
return {
|
|
1009
|
+
taskId: t.taskId,
|
|
1010
|
+
requested,
|
|
1011
|
+
status: t.status,
|
|
1012
|
+
note: requested
|
|
1013
|
+
? '副机侧任务会在下一个检查点停下;用 remote_status 确认最终状态。'
|
|
1014
|
+
: '节点当前不可达或任务已结束,取消请求没有送出。',
|
|
1015
|
+
}
|
|
1016
|
+
},
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
return disposers
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* 解析目标节点:显式 id/主机名优先;省略时唯一在线节点直接用,多节点则
|
|
1024
|
+
* 负载感知自动选(提案 #14:在跑任务数 → CPU → 内存 → id 逐级比较,
|
|
1025
|
+
* mode=agent 只考虑支持 agent 后端的节点)。
|
|
1026
|
+
*/
|
|
1027
|
+
export function resolveNodeId(registry, wanted, { mode } = {}) {
|
|
1028
|
+
if (typeof wanted === 'string' && wanted.trim() !== '') {
|
|
1029
|
+
const rec = registry.pickNode(wanted)
|
|
1030
|
+
if (!rec) throw new RemoteAgentError(`unknown node: ${wanted}`, ERROR_CODE.TASK_UNKNOWN)
|
|
1031
|
+
if (!rec.online) throw new RemoteAgentError(`node offline: ${rec.nodeId}`, ERROR_CODE.INTERNAL)
|
|
1032
|
+
return rec.nodeId
|
|
1033
|
+
}
|
|
1034
|
+
const online = registry.onlineNodes()
|
|
1035
|
+
if (online.length === 0) {
|
|
1036
|
+
throw new RemoteAgentError('no online node: 先让副机接入(remote_nodes 可看当前状态)', ERROR_CODE.INTERNAL)
|
|
1037
|
+
}
|
|
1038
|
+
// 多节点不再强制显式指定(提案 #14):负载感知自动选;选不出有能力匹配的
|
|
1039
|
+
// 节点(如 agent 模式但没有 agentMode 的节点)才报错并点名原因。
|
|
1040
|
+
const auto = registry.pickAutoNode({ mode })
|
|
1041
|
+
if (!auto) {
|
|
1042
|
+
throw new RemoteAgentError(
|
|
1043
|
+
mode === 'agent'
|
|
1044
|
+
? 'no online node supports agent mode(agentMode); 请显式指定 node 或改用 mode:"exec"'
|
|
1045
|
+
: 'no online node: 先让副机接入(remote_nodes 可看当前状态)',
|
|
1046
|
+
ERROR_CODE.INTERNAL,
|
|
1047
|
+
)
|
|
1048
|
+
}
|
|
1049
|
+
return auto
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function requireTask(registry, taskId) {
|
|
1053
|
+
if (typeof taskId !== 'string' || taskId === '') {
|
|
1054
|
+
throw new RemoteAgentError('taskId is required', ERROR_CODE.TASK_UNKNOWN)
|
|
1055
|
+
}
|
|
1056
|
+
const t = registry.getTask(taskId)
|
|
1057
|
+
if (!t) throw new RemoteAgentError(`unknown taskId: ${taskId}`, ERROR_CODE.TASK_UNKNOWN)
|
|
1058
|
+
return t
|
|
1059
|
+
}
|