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
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 副机侧的 agent 会话桥:把一条远程任务变成副机本地的一次真实 DSH agent 运行。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不是"在副机跑一条 shell 命令":
|
|
5
|
+
* 用户锁定的形态是 **B** —— 副机拥有独立 Agent 循环、独立模型上下文,
|
|
6
|
+
* 任务在副机本地持久运行,断连不终止。所以任务执行者必须是副机自己的 dsh。
|
|
7
|
+
*
|
|
8
|
+
* 两种后端,按可用性自动选择,对上层暴露同一个 run():
|
|
9
|
+
*
|
|
10
|
+
* 1. **in-process**(首选):worker 插件运行在副机 dsh 进程里,直接调
|
|
11
|
+
* `ctx.agentLoop.createAgent()` 起一个独立会话,把任务书 `followup()` 进它的
|
|
12
|
+
* inbox,`whenIdle()` 等它停稳,再从会话事件里取最终回答。
|
|
13
|
+
* 好处:没有第二个 dsh 进程、共享副机的模型配置与凭证(凭证本就在副机本地,不外传)。
|
|
14
|
+
*
|
|
15
|
+
* 2. **headless**(回退):worker 以独立进程运行(没有 dsh 上下文)时,spawn
|
|
16
|
+
* `dsh --profile <p> headless --json "<task>"`,解析 NDJSON 事件流取最终回答。
|
|
17
|
+
*/
|
|
18
|
+
import { spawn } from 'node:child_process'
|
|
19
|
+
import fs from 'node:fs'
|
|
20
|
+
import { PLATFORM, detectPlatform } from './exec.js'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 本插件向副机 agent 投递任务书时的**消息来源身份**。
|
|
24
|
+
*
|
|
25
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
26
|
+
* ⚠ 这个常量曾经写错,导致 agent 模式"假完成"(2026-10-15 定位)
|
|
27
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
28
|
+
*
|
|
29
|
+
* 旧值:`{ kind: 'plugin', plugin: 'dsh-remote-agent' }`
|
|
30
|
+
*
|
|
31
|
+
* DSH 的 `MessageSourceMap` 是一张**封闭的联合类型表**,合法 kind 有 17 种:
|
|
32
|
+
*
|
|
33
|
+
* user · model · tool · system-prompt · model-selection · user-approval ·
|
|
34
|
+
* ptc-mode · tool-registry · user-rpc · agent-message · subagent-settled ·
|
|
35
|
+
* skill-invocation · cordis-host-runner · goal · schedule ·
|
|
36
|
+
* compact-checkpoint · session-reference · user-question-reply
|
|
37
|
+
*
|
|
38
|
+
* **里面没有 `plugin`。** 传一个不认识的 kind 时,`followup` 表面上成功
|
|
39
|
+
* (消息进 inbox、驱动被唤醒),但 turn 一启动就抛错 —— 而 DSH 的 `kick()`
|
|
40
|
+
* 里是 `catch (_error) {}`,**错误被静默吞掉**。结果:
|
|
41
|
+
*
|
|
42
|
+
* `whenIdle()` 在 ~20ms 内返回,`liveText` 为空,任务被标成 completed,
|
|
43
|
+
* 产物为 0 —— 而**没有任何错误信息**(连日志都没有)。
|
|
44
|
+
*
|
|
45
|
+
* `cordis-host-runner` 正是为"cordis 宿主里的 runner 主动驱动一个 agent"
|
|
46
|
+
* 这个场景准备的 kind,语义与我们完全对应。
|
|
47
|
+
*
|
|
48
|
+
* ── 教训 ────────────────────────────────────────────────────────────────
|
|
49
|
+
* 往 DSH 的封闭联合类型里塞自造值**不会立刻报错**,而是在某个被
|
|
50
|
+
* `catch {}` 包住的深层位置静默失效。改这类常量前先查 `MessageSourceMap`
|
|
51
|
+
* 的完整声明(用 cordis_inspect 的 `listService` 查 `sessions`)。
|
|
52
|
+
*/
|
|
53
|
+
const PLUGIN_SOURCE = Object.freeze({ kind: 'cordis-host-runner' })
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 从 dsh headless 的 NDJSON 事件流里提取最终回答。
|
|
57
|
+
* 只认能确定识别的形状,认不出就保留原始尾部,绝不编造。
|
|
58
|
+
*/
|
|
59
|
+
export function extractFinalFromNdjson(text) {
|
|
60
|
+
const lines = text.split(/\r?\n/).filter((l) => l.trim() !== '')
|
|
61
|
+
let final = ''
|
|
62
|
+
const toolCalls = []
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
let ev
|
|
65
|
+
try {
|
|
66
|
+
ev = JSON.parse(line)
|
|
67
|
+
} catch {
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
if (!ev || typeof ev !== 'object') continue
|
|
71
|
+
const t = String(ev.type ?? ev.kind ?? '')
|
|
72
|
+
if (t === 'assistant/message' || t === 'assistant.message' || t === 'message') {
|
|
73
|
+
const blocks = ev.message?.content ?? ev.content ?? ev.blocks
|
|
74
|
+
if (Array.isArray(blocks)) {
|
|
75
|
+
const txt = blocks.filter((b) => b && b.type === 'text').map((b) => b.text ?? '').join('')
|
|
76
|
+
if (txt) final = txt
|
|
77
|
+
} else if (typeof ev.text === 'string') {
|
|
78
|
+
final = ev.text
|
|
79
|
+
}
|
|
80
|
+
} else if (t === 'tool/call' || t === 'tool.call') {
|
|
81
|
+
toolCalls.push({ name: ev.name ?? ev.toolName ?? 'unknown', at: Date.now() })
|
|
82
|
+
} else if (typeof ev.final === 'string') {
|
|
83
|
+
final = ev.final
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { final, toolCalls, eventCount: lines.length }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 在副机 PATH 上找 dsh 可执行文件。 */
|
|
90
|
+
export function resolveDshBin(explicit) {
|
|
91
|
+
const candidates = [explicit, process.env.DSH_BIN, process.env.DSH_REMOTE_DSH_BIN].filter(Boolean)
|
|
92
|
+
for (const c of candidates) {
|
|
93
|
+
if (fs.existsSync(c)) return c
|
|
94
|
+
}
|
|
95
|
+
return process.platform === 'win32' ? 'dsh.cmd' : 'dsh'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 从事件流里找 sessionId(记录在任务里,方便人在副机本地回溯这次会话)。 */
|
|
99
|
+
export function extractSessionId(text) {
|
|
100
|
+
const m = text.match(/"sessionId"\s*:\s*"([^"]+)"/)
|
|
101
|
+
return m ? m[1] : null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 从 agent 会话里抽取助手文本与工具调用名。 */
|
|
105
|
+
function readSessionOutcome(session) {
|
|
106
|
+
const textParts = []
|
|
107
|
+
const toolCalls = []
|
|
108
|
+
if (!session || typeof session.ownEvents !== 'function') return { textParts, toolCalls }
|
|
109
|
+
let events
|
|
110
|
+
try {
|
|
111
|
+
events = session.ownEvents()
|
|
112
|
+
} catch {
|
|
113
|
+
return { textParts, toolCalls }
|
|
114
|
+
}
|
|
115
|
+
for (const ev of events) {
|
|
116
|
+
if (ev.type === 'assistant/message') {
|
|
117
|
+
const blocks = ev.data?.message?.content ?? []
|
|
118
|
+
const txt = blocks.filter((b) => b?.type === 'text').map((b) => b.text ?? '').join('')
|
|
119
|
+
if (txt) textParts.push(txt)
|
|
120
|
+
} else if (ev.type === 'tool/call') {
|
|
121
|
+
toolCalls.push({ name: ev.data?.name ?? 'unknown', at: ev.time ?? Date.now() })
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { textParts, toolCalls }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* headless 后端:spawn 一个副机本地 dsh 进程跑任务。
|
|
129
|
+
*/
|
|
130
|
+
export class HeadlessRunner {
|
|
131
|
+
constructor({ dshBin, profile, platform = detectPlatform(), extraArgs = [] } = {}) {
|
|
132
|
+
this.dshBin = resolveDshBin(dshBin)
|
|
133
|
+
this.profile = profile
|
|
134
|
+
this.platform = platform
|
|
135
|
+
this.extraArgs = extraArgs
|
|
136
|
+
this.children = new Set()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
get kind() {
|
|
140
|
+
return 'headless'
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {object} o
|
|
145
|
+
* @param {string} o.goal 任务书
|
|
146
|
+
* @param {string} o.cwd 工作区(副机绝对路径)
|
|
147
|
+
* @param {number} o.timeoutMs
|
|
148
|
+
* @param {AbortSignal} [o.signal]
|
|
149
|
+
* @param {(chunk:string, stream:string)=>void} [o.onData]
|
|
150
|
+
*/
|
|
151
|
+
run(o) {
|
|
152
|
+
const { goal, cwd, timeoutMs, signal, onData } = o
|
|
153
|
+
|
|
154
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
155
|
+
// headless 后端不支持主机指定模型 —— 明确报错,不静默忽略
|
|
156
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
157
|
+
//
|
|
158
|
+
// `dsh headless` 的 run() 是 `const selection = defaultModel.currentSelection()`
|
|
159
|
+
// —— 模型**硬编码**为副机自己的默认选择,CLI 没有模型 flag(已对照
|
|
160
|
+
// app.asar 里的 dsh-headless 源码核实,2026-10-15)。
|
|
161
|
+
//
|
|
162
|
+
// 静默忽略是最坏的回退:主机以为派给了指定模型,副机实际跑的是默认模型
|
|
163
|
+
// —— 结果对得上、行为对不上,排查方向直接跑偏。所以这里如实失败,
|
|
164
|
+
// 让 hub 端看到明确的错误信息。
|
|
165
|
+
if (o.model && (o.model.model || o.model.provider)) {
|
|
166
|
+
return Promise.resolve({
|
|
167
|
+
ok: false,
|
|
168
|
+
exitCode: null,
|
|
169
|
+
final: '',
|
|
170
|
+
stderr:
|
|
171
|
+
'headless 后端不支持指定模型(dsh headless 固定使用副机默认模型)。'
|
|
172
|
+
+ '请在副机使用 in-process 后端(默认),或不指定 model 参数。',
|
|
173
|
+
toolCalls: [],
|
|
174
|
+
durationMs: 0,
|
|
175
|
+
sessionId: null,
|
|
176
|
+
modelUnsupported: true,
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
181
|
+
// 命令构造:**绝不能把 goal 拼进命令行**
|
|
182
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
183
|
+
//
|
|
184
|
+
// 旧实现:
|
|
185
|
+
// args.push('headless', '--json', ...this.extraArgs, goal)
|
|
186
|
+
// spawn(this.dshBin, args, { shell: this.platform === PLATFORM.WINDOWS })
|
|
187
|
+
//
|
|
188
|
+
// 两个问题叠加成一个**命令注入**向量(2026-10-15 独立评审发现):
|
|
189
|
+
//
|
|
190
|
+
// 1. `shell: true` 时 Node 把 args **用空格拼接**成一个命令行字符串,
|
|
191
|
+
// 交给 `cmd.exe /c` —— **不做任何转义**(Windows 上尤其如此)。
|
|
192
|
+
// 2. `goal` 是 `buildTaskBrief()` 生成的**多行 Markdown 任务书**,
|
|
193
|
+
// 含换行、`#`、`-`、引号,且内容来自远端**不可信**(任务书由 hub 侧
|
|
194
|
+
// 的调用方给出,中间还经过协议传输)。
|
|
195
|
+
//
|
|
196
|
+
// 后果:任务书里出现 `&`、`|`、`"` 或换行时,命令行会被 cmd 截断/拆开 ——
|
|
197
|
+
// 轻则调用损坏(表现为"任务莫名失败"),重则**执行任务书里夹带的第二条命令**。
|
|
198
|
+
//
|
|
199
|
+
// ── 修法 ──────────────────────────────────────────────────────────────
|
|
200
|
+
// ① **去掉 `shell: true`** —— `dshBin` 已由 `resolveDshBin()` 解析成绝对
|
|
201
|
+
// 路径,不需要 shell 参与解析。这样 args 走真正的 argv 传递,
|
|
202
|
+
// 元字符不再有特殊含义。
|
|
203
|
+
// ② **goal 从 stdin 传**,不进 argv —— 这是纵深防御:即使将来有人把
|
|
204
|
+
// `shell: true` 加回来,goal 也不在命令行上。
|
|
205
|
+
// (`--json` 模式下 dsh headless 从 stdin 读任务书;见 bin 的用法。)
|
|
206
|
+
//
|
|
207
|
+
// 注意 `--` 分隔符:它告诉 argv 解析器"后面全是位置参数",
|
|
208
|
+
// 防止 goal 以 `-` 开头时被当成选项。
|
|
209
|
+
const args = []
|
|
210
|
+
if (this.profile) args.push('--profile', this.profile)
|
|
211
|
+
args.push('headless', '--json', ...this.extraArgs, '-')
|
|
212
|
+
const useStdinForGoal = true
|
|
213
|
+
|
|
214
|
+
const startedAt = Date.now()
|
|
215
|
+
return new Promise((resolve) => {
|
|
216
|
+
let child
|
|
217
|
+
try {
|
|
218
|
+
child = spawn(this.dshBin, args, {
|
|
219
|
+
cwd: cwd && fs.existsSync(cwd) ? cwd : undefined,
|
|
220
|
+
env: { ...process.env },
|
|
221
|
+
windowsHide: true,
|
|
222
|
+
// stdin 要可写(goal 从那里进),stdout/stderr 照旧管道读。
|
|
223
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
224
|
+
// ⚠ 永远 false。见上面的说明 —— 这一行曾经是注入向量的成因之一。
|
|
225
|
+
shell: false,
|
|
226
|
+
})
|
|
227
|
+
} catch (err) {
|
|
228
|
+
resolve({
|
|
229
|
+
ok: false,
|
|
230
|
+
exitCode: null,
|
|
231
|
+
final: '',
|
|
232
|
+
stderr: String(err?.message ?? err),
|
|
233
|
+
toolCalls: [],
|
|
234
|
+
durationMs: 0,
|
|
235
|
+
sessionId: null,
|
|
236
|
+
spawnError: true,
|
|
237
|
+
})
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
this.children.add(child)
|
|
242
|
+
const outChunks = []
|
|
243
|
+
const errChunks = []
|
|
244
|
+
let settled = false
|
|
245
|
+
let timedOut = false
|
|
246
|
+
|
|
247
|
+
// 把任务书写进 stdin(argv 里传的是 `-`)。
|
|
248
|
+
// dsh headless 的文档明示:`task` 参数的 `-` 表示读 stdin。
|
|
249
|
+
// 这样 goal 完全不经过命令行 —— 元字符(`&` `|` `"` 换行)没有特殊含义。
|
|
250
|
+
//
|
|
251
|
+
// 写失败要处理:子进程可能立刻退出(例如 profile 不存在),此时 stdin
|
|
252
|
+
// 已关闭,write 会触发 EPIPE。吞掉它即可 —— 真正的失败会由 exit code
|
|
253
|
+
// 与 stderr 反映。
|
|
254
|
+
try {
|
|
255
|
+
if (useStdinForGoal && child.stdin) {
|
|
256
|
+
child.stdin.on('error', () => { /* EPIPE:子进程提前退出,由 exit 反映 */ })
|
|
257
|
+
child.stdin.end(String(goal ?? ''), 'utf8')
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
/* 同上 */
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const killTree = () => {
|
|
264
|
+
try {
|
|
265
|
+
if (this.platform === PLATFORM.WINDOWS) {
|
|
266
|
+
spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
|
|
267
|
+
} else {
|
|
268
|
+
try {
|
|
269
|
+
process.kill(-child.pid, 'SIGKILL')
|
|
270
|
+
} catch {
|
|
271
|
+
child.kill('SIGKILL')
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
try {
|
|
276
|
+
child.kill('SIGKILL')
|
|
277
|
+
} catch {
|
|
278
|
+
/* 已退出 */
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const timer = timeoutMs > 0
|
|
284
|
+
? setTimeout(() => {
|
|
285
|
+
timedOut = true
|
|
286
|
+
killTree()
|
|
287
|
+
}, timeoutMs)
|
|
288
|
+
: undefined
|
|
289
|
+
if (timer?.unref) timer.unref()
|
|
290
|
+
|
|
291
|
+
const onAbort = () => killTree()
|
|
292
|
+
if (signal) {
|
|
293
|
+
if (signal.aborted) onAbort()
|
|
294
|
+
else signal.addEventListener('abort', onAbort, { once: true })
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
child.stdout?.on('data', (b) => {
|
|
298
|
+
const s = b.toString('utf8')
|
|
299
|
+
outChunks.push(s)
|
|
300
|
+
if (typeof onData === 'function') onData(s, 'stdout')
|
|
301
|
+
})
|
|
302
|
+
child.stderr?.on('data', (b) => {
|
|
303
|
+
const s = b.toString('utf8')
|
|
304
|
+
errChunks.push(s)
|
|
305
|
+
if (typeof onData === 'function') onData(s, 'stderr')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
const finish = (code) => {
|
|
309
|
+
if (settled) return
|
|
310
|
+
settled = true
|
|
311
|
+
this.children.delete(child)
|
|
312
|
+
if (timer) clearTimeout(timer)
|
|
313
|
+
if (signal) signal.removeEventListener('abort', onAbort)
|
|
314
|
+
const stdout = outChunks.join('')
|
|
315
|
+
const parsed = extractFinalFromNdjson(stdout)
|
|
316
|
+
resolve({
|
|
317
|
+
ok: code === 0 && !timedOut,
|
|
318
|
+
exitCode: code,
|
|
319
|
+
timedOut,
|
|
320
|
+
final: parsed.final,
|
|
321
|
+
rawTail: stdout.slice(-4000),
|
|
322
|
+
stderr: errChunks.join('').slice(-8000),
|
|
323
|
+
toolCalls: parsed.toolCalls,
|
|
324
|
+
eventCount: parsed.eventCount,
|
|
325
|
+
durationMs: Date.now() - startedAt,
|
|
326
|
+
sessionId: extractSessionId(stdout),
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
child.on('error', (err) => {
|
|
331
|
+
errChunks.push(String(err?.message ?? err))
|
|
332
|
+
finish(null)
|
|
333
|
+
})
|
|
334
|
+
child.on('close', (code) => finish(code))
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
killAll() {
|
|
339
|
+
for (const c of this.children) {
|
|
340
|
+
try {
|
|
341
|
+
c.kill('SIGKILL')
|
|
342
|
+
} catch {
|
|
343
|
+
/* 已退出 */
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
this.children.clear()
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* 把"副机默认模型选择"与"主机指定的模型选择"合成 createAgent 的 agentOptions。
|
|
352
|
+
*
|
|
353
|
+
* 合成规则(主机指定的字段优先):
|
|
354
|
+
* - 两边都是 {provider, model} 成对 → 直接用主机的
|
|
355
|
+
* - 主机只给了 model → provider 用副机默认的("副机默认 provider + 主机指定的模型")
|
|
356
|
+
* - 主机只给了 provider → 无效(provider 必须配 model),退回默认
|
|
357
|
+
* - 主机什么都没给 → 副机默认(与 2026-10-15 修复后的行为一致)
|
|
358
|
+
* - 默认也没有 → undefined(DSH 自己的默认行为)
|
|
359
|
+
*
|
|
360
|
+
* reasoningEffort:主机给了就用主机的;没给继承默认。
|
|
361
|
+
*
|
|
362
|
+
* 纯函数,无副作用 —— 便于单测穷举合成矩阵。
|
|
363
|
+
*/
|
|
364
|
+
export function buildAgentOptions(defaultSel, requested) {
|
|
365
|
+
const cleanPair = (sel) => {
|
|
366
|
+
if (!sel || typeof sel !== 'object') return undefined
|
|
367
|
+
const o = {}
|
|
368
|
+
if (typeof sel.provider === 'string' && sel.provider !== '') o.provider = sel.provider
|
|
369
|
+
if (typeof sel.model === 'string' && sel.model !== '') o.model = sel.model
|
|
370
|
+
if (sel.reasoningEffort !== undefined && sel.reasoningEffort !== null) {
|
|
371
|
+
o.reasoningEffort = sel.reasoningEffort
|
|
372
|
+
}
|
|
373
|
+
// provider 与 model 必须成对 —— 只给一个是无效选择,不如不给。
|
|
374
|
+
return o.provider && o.model ? o : undefined
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const base = cleanPair(defaultSel) ?? {}
|
|
378
|
+
const req = requested && typeof requested === 'object' ? requested : {}
|
|
379
|
+
|
|
380
|
+
const model = typeof req.model === 'string' && req.model !== '' ? req.model : base.model
|
|
381
|
+
const provider =
|
|
382
|
+
typeof req.provider === 'string' && req.provider !== ''
|
|
383
|
+
? req.provider
|
|
384
|
+
: typeof req.model === 'string' && req.model !== ''
|
|
385
|
+
? base.provider // 只指定模型 → 借副机默认 provider
|
|
386
|
+
: base.provider
|
|
387
|
+
|
|
388
|
+
if (!provider || !model) return base.provider && base.model ? base : undefined
|
|
389
|
+
|
|
390
|
+
const out = { provider, model }
|
|
391
|
+
const effort =
|
|
392
|
+
typeof req.reasoningEffort === 'string' && req.reasoningEffort !== ''
|
|
393
|
+
? req.reasoningEffort
|
|
394
|
+
: base.reasoningEffort
|
|
395
|
+
if (effort) out.reasoningEffort = effort
|
|
396
|
+
return out
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* in-process 后端:在副机 dsh 进程里起一个真实 agent 会话。
|
|
401
|
+
*
|
|
402
|
+
* 用 ctx.agentLoop.createAgent() 创建带独立 id 的会话,把任务书 followup 进 inbox,
|
|
403
|
+
* 用 whenIdle() 等它停稳。这比 headless 少一个进程,且直接复用副机的
|
|
404
|
+
* 模型配置 / 凭证 / 插件 —— 凭证一步都没有离开副机。
|
|
405
|
+
*/
|
|
406
|
+
export class InProcessRunner {
|
|
407
|
+
/**
|
|
408
|
+
* @param {object} o
|
|
409
|
+
* @param {import('@deepseek-ai/cordis').Context} o.ctx 副机 dsh 的根上下文
|
|
410
|
+
* @param {string} o.nodeId
|
|
411
|
+
* @param {string} [o.preset] 指定 agent preset(可选)
|
|
412
|
+
*/
|
|
413
|
+
constructor({ ctx, nodeId, preset }) {
|
|
414
|
+
this.ctx = ctx
|
|
415
|
+
this.nodeId = nodeId
|
|
416
|
+
this.preset = preset
|
|
417
|
+
this.active = new Map() // taskId → { handle, controller }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
get kind() {
|
|
421
|
+
return 'in-process'
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* 这两个服务是否可用。
|
|
426
|
+
*
|
|
427
|
+
* 必须用 `ctx.get(name)` 而不是 `ctx.agentLoop` —— 在 cordis 里访问一个
|
|
428
|
+
* **未在 inject 中声明**的服务属性会**抛错**(`cannot get property "agentLoop"
|
|
429
|
+
* without inject`),而不是返回 undefined。所以可选链 `ctx?.agentLoop` 挡不住它,
|
|
430
|
+
* 反而让"探测可用性"这一步自己炸掉:实测在 headless profile(无 agentLoop)里
|
|
431
|
+
* 启动 worker 会得到 `worker disabled after startup error cannot get property
|
|
432
|
+
* "agentLoop" without inject` —— 一个本该优雅降级的地方变成了启动失败。
|
|
433
|
+
*/
|
|
434
|
+
get available() {
|
|
435
|
+
const ctx = this.ctx
|
|
436
|
+
if (!ctx || typeof ctx.get !== 'function') return false
|
|
437
|
+
try {
|
|
438
|
+
return Boolean(ctx.get('agentLoop') && ctx.get('sessions'))
|
|
439
|
+
} catch {
|
|
440
|
+
// get() 本身在某些组合下也可能抛;探测失败一律当作"不可用",交给 headless 后端
|
|
441
|
+
return false
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* @param {object} o
|
|
447
|
+
* @param {string} o.taskId
|
|
448
|
+
* @param {string} o.goal
|
|
449
|
+
* @param {string} o.cwd
|
|
450
|
+
* @param {number} o.timeoutMs
|
|
451
|
+
* @param {AbortSignal} [o.signal]
|
|
452
|
+
* @param {(evt:object)=>void} [o.onEvent] 会话事件回调(用于进度)
|
|
453
|
+
*/
|
|
454
|
+
async run(o) {
|
|
455
|
+
const { taskId, goal, cwd, timeoutMs, signal, onEvent } = o
|
|
456
|
+
const startedAt = Date.now()
|
|
457
|
+
|
|
458
|
+
if (!this.available) {
|
|
459
|
+
return {
|
|
460
|
+
ok: false,
|
|
461
|
+
exitCode: null,
|
|
462
|
+
final: '',
|
|
463
|
+
stderr: 'agentLoop/sessions service unavailable in this dsh composition',
|
|
464
|
+
toolCalls: [],
|
|
465
|
+
durationMs: 0,
|
|
466
|
+
sessionId: null,
|
|
467
|
+
unavailable: true,
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const controller = new AbortController()
|
|
472
|
+
const onOuterAbort = () => controller.abort()
|
|
473
|
+
if (signal) {
|
|
474
|
+
if (signal.aborted) controller.abort()
|
|
475
|
+
else signal.addEventListener('abort', onOuterAbort, { once: true })
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
let timer
|
|
479
|
+
let timedOut = false
|
|
480
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
481
|
+
if (!(timeoutMs > 0)) return
|
|
482
|
+
timer = setTimeout(() => {
|
|
483
|
+
timedOut = true
|
|
484
|
+
controller.abort()
|
|
485
|
+
resolve({ timedOut: true })
|
|
486
|
+
}, timeoutMs)
|
|
487
|
+
if (timer.unref) timer.unref()
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
const sessionIdFor = `session-remote-${String(taskId).replace(/[^A-Za-z0-9-]/g, '').slice(0, 48)}`
|
|
491
|
+
let handle
|
|
492
|
+
let disposeListener
|
|
493
|
+
let disposeAgentErrorListener
|
|
494
|
+
const liveText = []
|
|
495
|
+
/**
|
|
496
|
+
* agent 后端的**按需**流程日志。
|
|
497
|
+
*
|
|
498
|
+
* ═══════════════════════════════════════════════════════════════════════
|
|
499
|
+
* 这个东西救过一次大忙,但默认必须是关的(2026-10-15 两次修正)
|
|
500
|
+
* ═══════════════════════════════════════════════════════════════════════
|
|
501
|
+
*
|
|
502
|
+
* **为什么留着**:它定位了「agent 模式 20ms 假完成、零输出、零报错」——
|
|
503
|
+
* 一个 DSH 用 `catch (_error) {}` 吞掉错误、因而完全不可观测的故障
|
|
504
|
+
* (根因是消息 source 类型非法,见 CHANGELOG)。没有它这条路径是黑的。
|
|
505
|
+
*
|
|
506
|
+
* **为什么默认关**:第一版写死 `<temp>/dsh-remote-agent-runner.log`、
|
|
507
|
+
* 无条件 `appendFileSync`,有两个问题:
|
|
508
|
+
* 1. **无限增长** —— 长期运行的副机会把这个文件刷到任意大小
|
|
509
|
+
* 2. **泄漏信息** —— 记录里含 taskId、goal 长度、cwd、错误正文
|
|
510
|
+
*
|
|
511
|
+
* **怎么开**:设环境变量 `DSH_REMOTE_AGENT_TRACE=1`(或 `true`/`yes`)。
|
|
512
|
+
* 排查时临时开、排查完关掉。同时受 `MAX_TRACE_BYTES` 约束:
|
|
513
|
+
* 超过就整文件轮转一次(只留最近的一段),不会无限长。
|
|
514
|
+
*/
|
|
515
|
+
const TRACE_ON = /^(1|true|yes|on)$/i.test(String(process.env.DSH_REMOTE_AGENT_TRACE ?? ''))
|
|
516
|
+
const traceFile = `${process.env.TEMP || process.env.TMP || '/tmp'}/dsh-remote-agent-runner.log`
|
|
517
|
+
const MAX_TRACE_BYTES = 2 * 1024 * 1024
|
|
518
|
+
const dlog = (msg, extra) => {
|
|
519
|
+
if (!TRACE_ON) return
|
|
520
|
+
try {
|
|
521
|
+
// 轮转:超上限就换成只写开头一行标记,避免无限增长。
|
|
522
|
+
try {
|
|
523
|
+
const st = fs.statSync(traceFile)
|
|
524
|
+
if (st.size > MAX_TRACE_BYTES) {
|
|
525
|
+
fs.writeFileSync(traceFile, `${new Date().toISOString()} [trace rotated, previous exceeded ${MAX_TRACE_BYTES} bytes]\n`, 'utf8')
|
|
526
|
+
}
|
|
527
|
+
} catch {
|
|
528
|
+
/* 文件不存在 = 首次写,无需轮转 */
|
|
529
|
+
}
|
|
530
|
+
fs.appendFileSync(traceFile, `${new Date().toISOString()} ${msg}${extra ? ' ' + JSON.stringify(extra) : ''}\n`, 'utf8')
|
|
531
|
+
} catch {
|
|
532
|
+
/* 诊断不影响主流程 */
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
dlog('InProcessRunner.run ENTER', {
|
|
536
|
+
taskId,
|
|
537
|
+
goalLen: String(goal ?? '').length,
|
|
538
|
+
cwd,
|
|
539
|
+
timeoutMs,
|
|
540
|
+
available: this.available,
|
|
541
|
+
hasAgentLoop: Boolean(this.ctx?.get?.('agentLoop')),
|
|
542
|
+
hasSessions: Boolean(this.ctx?.get?.('sessions')),
|
|
543
|
+
// 注意:agentDefaultModel 缺了**不算不可用** —— 它只是"能提供默认模型"。
|
|
544
|
+
// 缺了会走"不指定模型"的路径,而那条路会在 system prompt 组装时报
|
|
545
|
+
// `{{model}} has no value`(见下面 createAgent 处的说明)。
|
|
546
|
+
// 所以这里记下来,便于区分"服务缺失"与"部署没配默认模型"。
|
|
547
|
+
hasAgentDefaultModel: Boolean(this.ctx?.get?.('agentDefaultModel')),
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
try {
|
|
551
|
+
// 用 ctx.get() 取服务,不用属性访问 —— 见 available getter 的说明
|
|
552
|
+
const agentLoop = this.ctx.get('agentLoop')
|
|
553
|
+
const sessions = this.ctx.get('sessions')
|
|
554
|
+
|
|
555
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
556
|
+
// 必须显式带上模型选择 —— 否则 turn 1 就抛错(2026-10-15 定位)
|
|
557
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
558
|
+
//
|
|
559
|
+
// 旧实现传 `agentOptions: undefined`,即"不指定模型"。结果是
|
|
560
|
+
// system prompt 组装时报:
|
|
561
|
+
// prompt variable "{{model}}" has no value for this assembly
|
|
562
|
+
// (section "deployment:persona-prefix")
|
|
563
|
+
//
|
|
564
|
+
// 即:persona 前缀段用了 `{{model}}` 变量,而**没有选定模型时它没有值**。
|
|
565
|
+
// 而 DSH 的 `kick()` 是 `catch (_error) {}` —— 错误被吞、phase 转 idle、
|
|
566
|
+
// `whenIdle()` 立即返回,任务被标 completed 且产物为空,**全程无报错**。
|
|
567
|
+
//
|
|
568
|
+
// 正解:从 `agentDefaultModel` 取部署的默认选择填进去,再叠加主机在
|
|
569
|
+
// 派单时指定的 `model`(remote_task 的可选参数)。合成规则见
|
|
570
|
+
// buildAgentOptions —— 主机指定的字段优先,缺的字段借副机默认补齐。
|
|
571
|
+
// `currentSelection()` 的文档:"Read the current default model selection.
|
|
572
|
+
// returns a detached provider, model, and optional reasoning selection."
|
|
573
|
+
// 这个服务是**可选**的(用 ctx.get 探测),取不到时只靠主机指定 ——
|
|
574
|
+
// 若主机也没指定,则回到"不传"路径(部署配置问题,不是本插件能补救的)。
|
|
575
|
+
const agentOptions = buildAgentOptions(
|
|
576
|
+
(() => {
|
|
577
|
+
try {
|
|
578
|
+
const dm = typeof this.ctx.get === 'function' ? this.ctx.get('agentDefaultModel') : undefined
|
|
579
|
+
return dm && typeof dm.currentSelection === 'function' ? dm.currentSelection() : undefined
|
|
580
|
+
} catch {
|
|
581
|
+
return undefined
|
|
582
|
+
}
|
|
583
|
+
})(),
|
|
584
|
+
o.model,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
dlog('before createAgent', { sessionIdFor, cwd, agentOptions })
|
|
588
|
+
handle = await agentLoop.createAgent(this.ctx, {
|
|
589
|
+
sessionId: sessionIdFor,
|
|
590
|
+
meta: { cwd },
|
|
591
|
+
signal: controller.signal,
|
|
592
|
+
agentOptions,
|
|
593
|
+
})
|
|
594
|
+
const agent = handle.agent
|
|
595
|
+
const session = agent.session ?? sessions.get(agent.id)
|
|
596
|
+
dlog('after createAgent', {
|
|
597
|
+
agentId: agent?.id ?? null,
|
|
598
|
+
hasSession: Boolean(session),
|
|
599
|
+
sessionStatus: session?.status ?? null,
|
|
600
|
+
handleKeys: (() => { try { return Object.keys(handle ?? {}).slice(0, 10) } catch { return null } })(),
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
this.active.set(taskId, { handle, controller })
|
|
604
|
+
|
|
605
|
+
// 观察事件:给 hub 送进度,并在会话日志之外保留一份实时文本(防日志投影差异)
|
|
606
|
+
if (typeof this.ctx.on === 'function') {
|
|
607
|
+
disposeListener = this.ctx.on('session/event', (s, event) => {
|
|
608
|
+
if (!s || s.id !== agent.id) return
|
|
609
|
+
if (event.type === 'assistant/message') {
|
|
610
|
+
const blocks = event.data?.message?.content ?? []
|
|
611
|
+
const txt = blocks.filter((b) => b?.type === 'text').map((b) => b.text ?? '').join('')
|
|
612
|
+
if (txt) {
|
|
613
|
+
liveText.push(txt)
|
|
614
|
+
if (typeof onEvent === 'function') onEvent({ kind: 'assistant', text: txt.slice(0, 2000) })
|
|
615
|
+
}
|
|
616
|
+
} else if (event.type === 'tool/call') {
|
|
617
|
+
if (typeof onEvent === 'function') onEvent({ kind: 'tool', name: event.data?.name ?? 'unknown' })
|
|
618
|
+
}
|
|
619
|
+
})
|
|
620
|
+
}
|
|
621
|
+
// ⚠ 必须单独监听 `agent/error` —— 否则 turn 内部的错误**完全不可见**。
|
|
622
|
+
//
|
|
623
|
+
// DSH 的 `kick()` 里是 `try { while (await this.turn()) } catch (_error) {}`:
|
|
624
|
+
// 任何 turn 错误都被吞掉,只通过 `agent/error` 事件暴露(见 Agent 的
|
|
625
|
+
// `throwError()`)。我们曾因为漏了这条监听,把一个"消息 source 类型非法"
|
|
626
|
+
// 的错误查了很久 —— 表现只是"任务 20ms 完成、无输出、无报错"。
|
|
627
|
+
//
|
|
628
|
+
// 这里把错误记进诊断日志,并塞进 liveText,让它在 handoff 摘要里也可见。
|
|
629
|
+
//
|
|
630
|
+
// ⚠ 错误对象的形状不受我们控制:第一版只取 `err?.message`,结果记到的是
|
|
631
|
+
// `[object Object]`(DSH 发的 payload 是嵌套对象,没有顶层 message)。
|
|
632
|
+
// 所以这里做**深度序列化**:先试常见字段,再退到 JSON.stringify,
|
|
633
|
+
// 最后兜底到 String()。任何一步都不能抛。
|
|
634
|
+
const describeError = (e) => {
|
|
635
|
+
if (e === null || e === undefined) return String(e)
|
|
636
|
+
if (typeof e === 'string') return e
|
|
637
|
+
if (typeof e !== 'object') return String(e)
|
|
638
|
+
// 常见字段优先(FiberError / CordisError / LlmFailure 之类)
|
|
639
|
+
for (const k of ['message', 'reason', 'error', 'failure', 'code', 'name']) {
|
|
640
|
+
const v = e[k]
|
|
641
|
+
if (typeof v === 'string' && v !== '') {
|
|
642
|
+
return k === 'message' ? v : `${k}=${v}`
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
// 深度:把可枚举字段拼出来(含嵌套一层)
|
|
646
|
+
try {
|
|
647
|
+
const flat = {}
|
|
648
|
+
for (const k of Object.keys(e)) {
|
|
649
|
+
const v = e[k]
|
|
650
|
+
if (v === undefined) continue
|
|
651
|
+
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') flat[k] = v
|
|
652
|
+
else if (v === null) flat[k] = null
|
|
653
|
+
else if (typeof v === 'object') {
|
|
654
|
+
// 嵌套对象再取一层常见字段,避免无限深
|
|
655
|
+
const inner = {}
|
|
656
|
+
for (const k2 of ['message', 'code', 'name', 'kind', 'status', 'failure']) {
|
|
657
|
+
const v2 = v[k2]
|
|
658
|
+
if (typeof v2 === 'string' || typeof v2 === 'number' || typeof v2 === 'boolean') inner[k2] = v2
|
|
659
|
+
}
|
|
660
|
+
flat[k] = Object.keys(inner).length > 0 ? inner : String(v)
|
|
661
|
+
} else flat[k] = String(v)
|
|
662
|
+
}
|
|
663
|
+
const s = JSON.stringify(flat)
|
|
664
|
+
if (s && s !== '{}') return s
|
|
665
|
+
} catch {
|
|
666
|
+
/* 循环引用等 */
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
return JSON.stringify(e)
|
|
670
|
+
} catch {
|
|
671
|
+
return String(e)
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (typeof this.ctx.on === 'function') {
|
|
676
|
+
disposeAgentErrorListener = this.ctx.on('agent/error', (err, agentInfo) => {
|
|
677
|
+
try {
|
|
678
|
+
const a = agentInfo?.agent ?? agentInfo
|
|
679
|
+
if (a && a.id !== undefined && agent.id !== undefined && a.id !== agent.id) return
|
|
680
|
+
const msg = describeError(err)
|
|
681
|
+
dlog('agent/error', { msg: msg.slice(0, 1200), rawKeys: (() => { try { return Object.keys(err ?? {}) } catch { return null } })() })
|
|
682
|
+
liveText.push(`[agent 错误] ${msg}`)
|
|
683
|
+
if (typeof onEvent === 'function') onEvent({ kind: 'error', text: msg.slice(0, 2000) })
|
|
684
|
+
} catch {
|
|
685
|
+
/* 诊断不影响主流程 */
|
|
686
|
+
}
|
|
687
|
+
})
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// 投递任务书:followup 排队下一轮并唤醒驱动器。
|
|
691
|
+
// 必须带 plugin source —— 省略 source 会被当成人类输入,这是不能继承的权限。
|
|
692
|
+
dlog('before followup', { hasFollowup: typeof agent.followup === 'function' })
|
|
693
|
+
agent.followup({
|
|
694
|
+
content: [{ type: 'text', text: goal }],
|
|
695
|
+
source: PLUGIN_SOURCE,
|
|
696
|
+
})
|
|
697
|
+
dlog('after followup, before whenIdle', { hasWhenIdle: typeof agent.whenIdle === 'function' })
|
|
698
|
+
|
|
699
|
+
const raced = await Promise.race([
|
|
700
|
+
agent.whenIdle().then(() => ({ timedOut: false })),
|
|
701
|
+
timeoutPromise,
|
|
702
|
+
])
|
|
703
|
+
if (raced.timedOut === true) timedOut = true
|
|
704
|
+
dlog('whenIdle resolved', { timedOut, liveTextLen: liveText.join('').length, elapsedMs: Date.now() - startedAt })
|
|
705
|
+
|
|
706
|
+
// 会话日志优先;日志取不到就用实时累积
|
|
707
|
+
const fromLog = readSessionOutcome(session)
|
|
708
|
+
const finalText = fromLog.textParts.length > 0 ? fromLog.textParts.join('\n\n').trim() : liveText.join('\n\n').trim()
|
|
709
|
+
dlog('outcome read', {
|
|
710
|
+
fromLogParts: fromLog.textParts.length,
|
|
711
|
+
fromLogToolCalls: fromLog.toolCalls.length,
|
|
712
|
+
finalLen: finalText.length,
|
|
713
|
+
finalHead: finalText.slice(0, 200),
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
return {
|
|
717
|
+
ok: !timedOut,
|
|
718
|
+
exitCode: timedOut ? null : 0,
|
|
719
|
+
timedOut,
|
|
720
|
+
final: finalText,
|
|
721
|
+
stderr: '',
|
|
722
|
+
toolCalls: fromLog.toolCalls,
|
|
723
|
+
durationMs: Date.now() - startedAt,
|
|
724
|
+
sessionId: agent.id,
|
|
725
|
+
}
|
|
726
|
+
} catch (err) {
|
|
727
|
+
return {
|
|
728
|
+
ok: false,
|
|
729
|
+
exitCode: null,
|
|
730
|
+
final: liveText.join('\n\n').trim(),
|
|
731
|
+
stderr: String(err?.message ?? err),
|
|
732
|
+
toolCalls: [],
|
|
733
|
+
durationMs: Date.now() - startedAt,
|
|
734
|
+
sessionId: handle?.agent?.id ?? sessionIdFor,
|
|
735
|
+
}
|
|
736
|
+
} finally {
|
|
737
|
+
if (timer) clearTimeout(timer)
|
|
738
|
+
if (signal) signal.removeEventListener('abort', onOuterAbort)
|
|
739
|
+
this.active.delete(taskId)
|
|
740
|
+
try {
|
|
741
|
+
disposeListener?.()
|
|
742
|
+
} catch {
|
|
743
|
+
/* 上下文已卸载 */
|
|
744
|
+
}
|
|
745
|
+
try {
|
|
746
|
+
disposeAgentErrorListener?.()
|
|
747
|
+
} catch {
|
|
748
|
+
/* 同上 */
|
|
749
|
+
}
|
|
750
|
+
try {
|
|
751
|
+
await handle?.dispose()
|
|
752
|
+
} catch {
|
|
753
|
+
/* agent 可能已自行结束 */
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** 中断一个在跑的任务(hub 发 task/cancel 时调用)。 */
|
|
759
|
+
async cancel(taskId) {
|
|
760
|
+
const rec = this.active.get(taskId)
|
|
761
|
+
if (!rec) return false
|
|
762
|
+
try {
|
|
763
|
+
rec.handle.agent.cancel({ kind: 'user' })
|
|
764
|
+
} catch {
|
|
765
|
+
/* agent 可能已停 */
|
|
766
|
+
}
|
|
767
|
+
try {
|
|
768
|
+
rec.controller.abort()
|
|
769
|
+
} catch {
|
|
770
|
+
/* 已中止 */
|
|
771
|
+
}
|
|
772
|
+
return true
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** 插件卸载:中断全部在跑任务(任务记录留在磁盘上,重启后可查)。 */
|
|
776
|
+
async disposeAll() {
|
|
777
|
+
for (const [taskId] of this.active) {
|
|
778
|
+
await this.cancel(taskId)
|
|
779
|
+
}
|
|
780
|
+
this.active.clear()
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* 按可用性挑一个后端。
|
|
786
|
+
* in-process 优先 —— 少一个进程,且模型凭证不用经过任何额外路径。
|
|
787
|
+
*/
|
|
788
|
+
export function selectRunner({ ctx, nodeId, profile, dshBin, preferHeadless = false }) {
|
|
789
|
+
const inProc = new InProcessRunner({ ctx, nodeId })
|
|
790
|
+
if (!preferHeadless && inProc.available) return inProc
|
|
791
|
+
return new HeadlessRunner({ dshBin, profile })
|
|
792
|
+
}
|