@wjj865-code/dsh-orchestrator 0.3.1 → 0.3.4

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/src/dispatch.js CHANGED
@@ -1,473 +1,473 @@
1
- // 派发层:把「建子会话 / 投递 / 停止」收成一个窄接口。
2
- // 后端二选一:subagent(默认,直连 ctx.subagents)或 team(旧的 agentTeams 名册)。
3
- import { randomUUID } from 'node:crypto'
4
- import { resolve as resolvePath } from 'node:path'
5
-
6
- let counter = 0
7
-
8
- function slugify(title) {
9
- const ascii = String(title ?? '')
10
- .toLowerCase()
11
- .replace(/[^a-z0-9]+/g, '-')
12
- .replace(/^-+|-+$/g, '')
13
- counter += 1
14
- return ascii === '' ? `task-${Date.now()}-${counter}` : `${ascii.slice(0, 32)}-${counter}`
15
- }
16
-
17
- const sleep = ms => ms <= 0 ? Promise.resolve() : new Promise(resolve => setTimeout(resolve, ms))
18
- const noop = () => {}
19
-
20
- const errorText = error => `${error?.code === undefined ? '' : `[${error.code}] `}${error?.message ?? error}`
21
-
22
- /** 两个目录是否同一个:Windows 大小写不敏感;解析失败就按原串比较。 */
23
- export function sameDirectory(left, right) {
24
- if (typeof left !== 'string' || typeof right !== 'string') return false
25
- try {
26
- const a = resolvePath(left)
27
- const b = resolvePath(right)
28
- return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
29
- } catch {
30
- return left === right
31
- }
32
- }
33
-
34
- // team 后端用到的 agentTeams 成员 API。rc.3 起平台把这组方法整体移除(只剩 state/transact
35
- // 与团队任务板),调用会报 is not a function;这里按服务方法探测,给出可读错误。
36
- const TEAM_METHODS = ['spawnTeammate', 'sendMessage', 'interrupt', 'listMembers']
37
-
38
- export function missingTeamMethods(agentTeams) {
39
- const source = agentTeams ?? {}
40
- return TEAM_METHODS.filter(name => typeof source[name] !== 'function')
41
- }
42
-
43
- export function teamUnavailableMessage(missing) {
44
- return `team 后端不可用:当前平台缺少 agentTeams.${missing.join('/')}`
45
- + '(rc.3 起平台移除了 agentTeams 成员 API),请把 backend 改回 subagent'
46
- }
47
-
48
- export function createTeamDispatcher({ agentTeams, agent, getAgent, provider = 'spawn', retryDelaysMs = [1000, 2000, 4000], onWarn = noop }) {
49
- // 能力探测在构造时做一次:缺方法立即给可读错误,不留到每次派发时报 is not a function。
50
- const missing = missingTeamMethods(agentTeams)
51
- if (missing.length > 0) throw new Error(teamUnavailableMessage(missing))
52
- const currentAgent = () => getAgent === undefined ? agent : getAgent()
53
- const requireAgent = () => {
54
- const current = currentAgent()
55
- if (current === undefined) throw new Error('未找到协调者会话:请配置 coordinatorSession(协调者会话 id),并确保该会话已加载')
56
- return current
57
- }
58
-
59
- return {
60
- slug(title) {
61
- return slugify(title)
62
- },
63
- async spawn({ name, description, prompt, signal, readOnly }) {
64
- // team 后端没有按会话设只读的通道:明确拒绝,避免台账标只读、实际可写。
65
- if (readOnly === true) throw new Error('team 后端不支持只读任务:只读需要 subagent 后端,请把 backend 改回 subagent')
66
- // 真实 spawnTeammate 要求 provider(fresh 用 'spawn')和 AbortSignal,缺一个都会被拒。
67
- const input = {
68
- name: name ?? slugify(String(prompt ?? '').slice(0, 20)),
69
- description: description ?? 'orchestrator task',
70
- prompt: [{ type: 'text', text: prompt }],
71
- context: 'fresh',
72
- provider,
73
- ...(signal === undefined ? {} : { signal }),
74
- }
75
- const result = await agentTeams.spawnTeammate(requireAgent(), input)
76
- const member = result.member
77
- return { id: member.id, name: member.name, sessionId: member.id, target: member.name }
78
- },
79
- async send(target, message, signal) {
80
- return agentTeams.sendMessage(requireAgent(), {
81
- target,
82
- content: [{ type: 'text', text: message }],
83
- ...(signal === undefined ? {} : { signal }),
84
- })
85
- },
86
- /** 续派消息:失败按 retryDelaysMs 重试;全部失败时抛出最后一次原因。 */
87
- async sendReliable(target, message, signal) {
88
- let lastError
89
- for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) {
90
- try {
91
- return await this.send(target, message, signal)
92
- } catch (error) {
93
- lastError = error
94
- if (attempt < retryDelaysMs.length) {
95
- onWarn(`续派第 ${attempt + 1} 次投递失败:${error?.message ?? error}`)
96
- await sleep(retryDelaysMs[attempt])
97
- }
98
- }
99
- }
100
- throw new Error(`投递失败:${lastError?.message ?? lastError}`)
101
- },
102
- async stop(target) {
103
- return agentTeams.interrupt(requireAgent(), target)
104
- },
105
- members() {
106
- return agentTeams.listMembers(requireAgent())
107
- },
108
- }
109
- }
110
-
111
- export function createSubagentDispatcher({ subagents, agent, getAgent, provider = 'spawn', workerPreset, retryDelaysMs = [1000, 2000, 4000], onWarn = noop, onEvent = noop, agents, sessions, sandboxPolicy, onSessionCreated, onAgentCreated, sessionWaitMs = 5000 }) {
112
- const currentAgent = () => getAgent === undefined ? agent : getAgent()
113
- const requireAgent = () => {
114
- const current = currentAgent()
115
- if (current === undefined) throw new Error('未找到协调者会话:请配置 coordinatorSession(协调者会话 id),并确保该会话已加载')
116
- return current
117
- }
118
- // rc.3 改名 listDescendants ; rc.1 是 listChildren。按服务方法探测,不按版本号猜。
119
- const listChildren = (parentId, signal) => typeof subagents.listDescendants === 'function'
120
- ? subagents.listDescendants(parentId, signal)
121
- : subagents.listChildren(parentId, signal)
122
- const parentSessionId = () => {
123
- const current = requireAgent()
124
- return current.session?.id ?? current.id
125
- }
126
- const live = new Map()
127
- // 只读子任务:派发时把 childId 记下来,agent/created 一到就设沙箱(压首回合窗口)。
128
- const readOnlyChildren = new Set()
129
- // 服务按调用时惰性取:插件加载顺序不保证 agents/sessions/sandboxPolicy 已就绪。
130
- const serviceOf = value => typeof value === 'function' ? value() : value
131
- // 可写根授权按 targetId → (Session 对象 → (目录 → disposer)) 记:同一会话 resume 前后可能有多个会话对象,
132
- // 注册只追加不替换;终态/stop 按 targetId 全量释放,旧对象不残留、新对象也不会没人管。
133
- // 幂等 key 仍是 (Session 对象, 目录):sandboxPolicy 内部按对象身份查 WeakMap,换对象就得重新注册。
134
- const writableRootGrants = new Map()
135
- const grantSessions = targetId => {
136
- let sessions = writableRootGrants.get(targetId)
137
- if (sessions === undefined) {
138
- sessions = new Map()
139
- writableRootGrants.set(targetId, sessions)
140
- }
141
- return sessions
142
- }
143
- const grantDirs = (targetId, session) => {
144
- const sessions = grantSessions(targetId)
145
- let dirs = sessions.get(session)
146
- if (dirs === undefined) {
147
- dirs = new Map()
148
- sessions.set(session, dirs)
149
- }
150
- return dirs
151
- }
152
- const childSessionOf = childId => {
153
- const childAgent = serviceOf(agents)?.get?.(childId)
154
- if (childAgent?.session !== undefined) return childAgent.session
155
- return serviceOf(sessions)?.get?.(childId)
156
- }
157
- // 只读模式:平台唯一写入口就是 session.append('sandbox/mode', { mode:'read-only' })
158
- // (导出的 setSandboxMode 只是它的薄包装)。只写目标 child session,不动全局默认与父会话。
159
- // 投影已有 read-only 时跳过:续派/重启 replay 都会保留,不需要重复 append。
160
- const ensureReadOnly = (childId, sessionHint) => {
161
- try {
162
- const session = sessionHint ?? childSessionOf(childId)
163
- if (session === undefined) return false
164
- const policy = serviceOf(sandboxPolicy)
165
- if (policy?.overrideOf?.(session) === 'read-only') return false
166
- if (typeof session.append !== 'function') return false
167
- session.append('sandbox/mode', { mode: 'read-only' })
168
- onEvent('subagent.read-only', { sessionId: childId, detail: { sessionId: session.id ?? childId } })
169
- return true
170
- } catch (error) {
171
- onWarn(`dsh-orchestrator: 设置只读沙箱失败:${errorText(error)}`)
172
- return false
173
- }
174
- }
175
- // agent/created 早于首个模型请求,能压掉「首回合还没设只读」的窗口。
176
- if (typeof onAgentCreated === 'function') {
177
- try {
178
- onAgentCreated(created => {
179
- if (created?.id === undefined || !readOnlyChildren.has(created.id)) return
180
- ensureReadOnly(created.id, created.session)
181
- })
182
- } catch (error) {
183
- onWarn(`dsh-orchestrator: 订阅 agent/created 失败:${errorText(error)}`)
184
- }
185
- }
186
- // 激活返回时子 Session 可能还没进表:等一次 session/created 再设只读,不阻塞派发。
187
- const ensureReadOnlyReady = childId => {
188
- if (childSessionOf(childId) !== undefined || typeof onSessionCreated !== 'function') {
189
- ensureReadOnly(childId)
190
- return
191
- }
192
- void resolveChildSession(childId).then(session => {
193
- if (session === undefined) {
194
- onWarn(`dsh-orchestrator: 未拿到子会话 ${childId},只读沙箱未设置`)
195
- return
196
- }
197
- ensureReadOnly(childId, session)
198
- }).catch(error => onWarn(`dsh-orchestrator: 设置只读沙箱失败:${errorText(error)}`))
199
- }
200
- // activation 返回时子 Session 可能还没进会话表:挂一次 session/created 等同一个 childId;
201
- // 超时只 warn、不重试,也不阻塞派发。判同只看 id,不看 header.cwd(那正是坏的字段)。
202
- const resolveChildSession = childId => {
203
- const found = childSessionOf(childId)
204
- if (found !== undefined) return Promise.resolve(found)
205
- if (typeof onSessionCreated !== 'function') return Promise.resolve(undefined)
206
- return new Promise(resolveChild => {
207
- let done = false
208
- let unsubscribe
209
- let timer
210
- const finish = session => {
211
- if (done) return
212
- done = true
213
- clearTimeout(timer)
214
- try { unsubscribe?.() } catch { /* 取消订阅失败不影响结果 */ }
215
- resolveChild(session)
216
- }
217
- timer = setTimeout(() => finish(undefined), sessionWaitMs)
218
- try {
219
- unsubscribe = onSessionCreated(event => {
220
- if (event?.id !== childId) return
221
- finish(childSessionOf(childId) ?? event)
222
- })
223
- } catch {
224
- finish(undefined)
225
- }
226
- })
227
- }
228
- /** 释放某个 targetId 名下**所有** Session 对象的全部可写根(含 resume 前后的多个对象)。 */
229
- const releaseWritableRoot = targetId => {
230
- const sessions = writableRootGrants.get(targetId)
231
- if (sessions === undefined) return
232
- writableRootGrants.delete(targetId)
233
- for (const dirs of sessions.values()) {
234
- for (const disposer of dirs.values()) {
235
- try {
236
- disposer()
237
- } catch (error) {
238
- onWarn(`dsh-orchestrator: 释放 target 可写根失败:${errorText(error)}`)
239
- }
240
- }
241
- }
242
- }
243
- // rc.3 平台缺陷:子会话 header.cwd 取父会话目录(childSessionMeta),spec.cwd 只记一条 working-directory 事件,
244
- // 而 sandboxPolicy 的 workspaceRoot 只读 session.header.cwd → workspace-write 下子会话写不进 target。
245
- // 兜法:给子会话登记 target 为可写根(幂等)。注意 Windows 上只对 harness 文件工具生效,pwsh 进程写仍会被拒。
246
- // wait=true 只用于派发(子 Session 可能晚几毫秒进表);续派走非等待解析,避免冷恢复时干等超时。
247
- const ensureWritableRoot = async (childId, target, { wait = false, warnOnMiss = true } = {}) => {
248
- if (typeof target !== 'string' || target === '') return
249
- const policy = serviceOf(sandboxPolicy)
250
- if (policy === undefined || typeof policy.registerWritableRoot !== 'function') return
251
- const session = wait ? await resolveChildSession(childId) : childSessionOf(childId)
252
- if (session === undefined) {
253
- if (warnOnMiss) onWarn(`dsh-orchestrator: 未拿到子会话 ${childId},未登记 target 可写根`)
254
- return
255
- }
256
- // 平台根已经是 target 时不用授权;同一个 (session 对象, 目录) 只注册一次。
257
- if (sameDirectory(target, session.header?.cwd)) return
258
- const dirs = grantDirs(childId, session)
259
- if (dirs.has(target)) return
260
- try {
261
- const disposer = policy.registerWritableRoot(session, target)
262
- if (typeof disposer === 'function') dirs.set(target, disposer)
263
- // 审计/对表用:sessionId + directory 都带上,能直接和 task_message 投递时间对齐。
264
- onEvent('subagent.writable-root', { sessionId: childId, detail: { directory: target } })
265
- } catch (error) {
266
- onWarn(`dsh-orchestrator: 登记 target 可写根失败:${errorText(error)}`)
267
- }
268
- }
269
-
270
- // delivery: 'caller' 时没人 await 会变成未处理拒绝,这里必须挂上;同时把 settle 抛给上层驱动队列。
271
- // 回调里的异常(日志/事件消费方抛错)也不能变成宿主层面的未处理拒绝。
272
- // 注意:settle 只是这一轮结束(continuable 会话还会续派),**不释放**可写根;释放留给 stop/终态清理。
273
- const track = activation => {
274
- live.set(activation.childId, activation)
275
- activation.result.then(
276
- result => {
277
- try {
278
- live.delete(activation.childId)
279
- readOnlyChildren.delete(activation.childId)
280
- onEvent('subagent.settled', {
281
- sessionId: activation.childId,
282
- detail: { stopReason: result?.stopReason ?? null },
283
- })
284
- } catch { /* 结算事件失败不能外溢 */ }
285
- },
286
- error => {
287
- try {
288
- live.delete(activation.childId)
289
- readOnlyChildren.delete(activation.childId)
290
- onEvent('subagent.failed', { sessionId: activation.childId, detail: { error: errorText(error) } })
291
- } catch { /* 结算事件失败不能外溢 */ }
292
- },
293
- ).catch(() => {})
294
- return activation
295
- }
296
-
297
- return {
298
- slug(title) {
299
- return slugify(title)
300
- },
301
- async spawn({ name, label, prompt, signal, taskId, cwd, readOnly }) {
302
- const parent = requireAgent()
303
- // agent 已创建但 session 还没挂上时,平台 startActivation 会在 parent.session 上取 .id 抛 TypeError;
304
- // 这里先给可读错误,并由 queue 当成可重试(等会话就绪)。
305
- if (parent?.session === undefined || parent.session === null) {
306
- throw new Error('协调者会话尚未就绪(agent.session 为空),稍后重试')
307
- }
308
- if (parent.id === undefined) throw new Error('协调者会话缺少 id,稍后重试')
309
- const childId = randomUUID()
310
- // 只读任务:在开始创建前记下 childId,agent/created 时立刻设沙箱;激活返回后再补一次幂等设置。
311
- if (readOnly === true) readOnlyChildren.add(childId)
312
- // 目标目录优先;没给就沿用父会话 cwd(保持旧行为)。
313
- const baseCwd = cwd ?? parent.session?.header?.cwd
314
- const activationPrompt = [{ type: 'text', text: String(prompt ?? '') }]
315
- // 子会话显式用 worker 预设,绕开 composeFrom 继承协调者的 preset restrict(探针 §1)。
316
- // 只有 spawn provider 支持 agentPreset;fork 没有该能力,带上去会被平台拒绝(UNSUPPORTED_CAPABILITY)。
317
- const agentPreset = provider === 'spawn' ? { agentPreset: workerPreset ?? 'dsh-orchestrator-worker' } : {}
318
- const location = baseCwd === undefined ? {} : { cwd: baseCwd }
319
- const base = {
320
- provider,
321
- label: label ?? name ?? slugify(String(prompt ?? '').slice(0, 20)),
322
- delivery: 'caller',
323
- signal: signal ?? new AbortController().signal,
324
- childId,
325
- }
326
- // DSH 0.2.2-rc.3(当前桌面)的 startActivation 是扁平 spec:spec.parent/spec.prompt/…
327
- const spec = { ...base, parent, prompt: activationPrompt, ...agentPreset, ...location }
328
- // rc.1 及更早:旧平台读 spec.request.*,扁平字段会被忽略。
329
- const legacySpec = {
330
- ...base,
331
- request: { parent, prompt: activationPrompt, ...agentPreset, ...location },
332
- }
333
- const shapeError = error => /reading '(parent|request)'|spec\.(parent|request)|request\.parent/.test(error?.message ?? '')
334
- let activation
335
- onEvent('subagent.spawn.requested', { taskId, detail: { childId, label: spec.label, provider } })
336
- try {
337
- try {
338
- activation = await subagents.startActivation(spec)
339
- } catch (error) {
340
- if (!shapeError(error)) throw error
341
- // 升级前的旧安装:扁平 spec 读不到 request.parent,回退一次旧包装。
342
- onEvent('subagent.spawn.legacy-spec', { taskId, detail: { childId, error: error?.message ?? String(error) } })
343
- activation = await subagents.startActivation(legacySpec)
344
- }
345
- } catch (error) {
346
- // 两次都失败:清掉只读登记,别攒废弃 UUID。
347
- readOnlyChildren.delete(childId)
348
- throw error
349
- }
350
- onEvent('subagent.spawn.accepted', {
351
- taskId,
352
- sessionId: activation.childId,
353
- detail: { childId: activation.childId, messageId: activation.messageId ?? null, provider },
354
- })
355
- track(activation)
356
- if (readOnly === true) ensureReadOnlyReady(activation.childId)
357
- // 目标目录才需要补可写根;只读任务绝不能注册可写根(否则「只读」有后门)。
358
- if (readOnly !== true && typeof cwd === 'string' && cwd !== '') {
359
- void ensureWritableRoot(activation.childId, cwd, { wait: true }).catch(error => {
360
- onWarn(`dsh-orchestrator: 登记 target 可写根失败:${errorText(error)}`)
361
- })
362
- }
363
- return { id: activation.childId, name: spec.label, sessionId: activation.childId, target: activation.childId }
364
- },
365
- async send(target, message, signal, targetDir, taskId, readOnly) {
366
- const sent = await subagents.sendMessage(requireAgent(), target, [{ type: 'text', text: message }], {
367
- signal: signal ?? new AbortController().signal,
368
- })
369
- // 投递成功先记事件(代码顺序:投递 → 注册),日志时间序可自证「注册在投递之后」。
370
- try {
371
- onEvent('task.message.sent', { taskId, sessionId: target, detail: { targetDir: targetDir ?? null } })
372
- } catch { /* 审计事件失败不能外溢 */ }
373
- // 只读任务续派:resume 后按同一会话日志仍应保留 read-only;这里幂等补一次(已有则跳过)。
374
- if (readOnly === true) ensureReadOnly(target)
375
- // 冷恢复/续派时子会话在发送前还取不到,sendMessage 之后才 resolve 出**新的 Session 对象**;
376
- // 投递成功后再幂等补注册(失败路径不注册;失败只 warn,不影响投递结果)。
377
- if (readOnly !== true) {
378
- try {
379
- await ensureWritableRoot(target, targetDir)
380
- } catch (error) {
381
- onWarn(`dsh-orchestrator: 续派后补注册可写根失败:${errorText(error)}`)
382
- }
383
- }
384
- return sent
385
- },
386
- async sendReliable(target, message, signal, targetDir, taskId, readOnly) {
387
- let lastError
388
- for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) {
389
- try {
390
- return await this.send(target, message, signal, targetDir, taskId, readOnly)
391
- } catch (error) {
392
- lastError = error
393
- if (attempt < retryDelaysMs.length) {
394
- onWarn(`续派第 ${attempt + 1} 次投递失败:${error?.message ?? error}`)
395
- await sleep(retryDelaysMs[attempt])
396
- }
397
- }
398
- }
399
- throw new Error(`投递失败:${lastError?.message ?? lastError}`)
400
- },
401
- /** 有本地 activation 直接 dispose(提前取消);没有(重启后)走 interrupt。两种都释放可写根。 */
402
- async stop(target) {
403
- try {
404
- const activation = live.get(target)
405
- if (activation !== undefined) {
406
- await activation.dispose()
407
- live.delete(target)
408
- return
409
- }
410
- // rc.3:interrupt(targetSessionId, authority),authority 只认 { kind: 'ancestor' | 'user' }。
411
- // task_stop 是用户动作,按平台 interruptByUser 的写法传 { kind: 'user' };裸 agent 不在契约里。
412
- // rc.1:interruptByParent(childId, parentSessionId, mode)。
413
- if (typeof subagents.interrupt === 'function') return subagents.interrupt(target, { kind: 'user' })
414
- return subagents.interruptByParent(target, parentSessionId(), 'continuable')
415
- } finally {
416
- releaseWritableRoot(target)
417
- }
418
- },
419
- /** 任务进入终态(done/cancelled/orphaned/failed)时由上层调用,释放该会话的可写根。 */
420
- releaseWritableRoots(target) {
421
- releaseWritableRoot(target)
422
- },
423
- members() {
424
- return listChildren(parentSessionId()).catch(error => {
425
- onWarn(`子会话目录读取失败:${errorText(error)}`)
426
- return []
427
- })
428
- },
429
- catalog() {
430
- return listChildren(parentSessionId())
431
- },
432
- has(target) {
433
- return live.has(target)
434
- },
435
- }
436
- }
437
-
438
- // 后端不可用时的占位派发器:派发类调用返回可读错误,目录类查询降级为空(不打断面板/孤儿巡检)。
439
- function createUnavailableDispatcher(reason) {
440
- const fail = () => { throw new Error(reason) }
441
- return {
442
- slug(title) {
443
- return slugify(title)
444
- },
445
- async spawn() { fail() },
446
- async send() { fail() },
447
- async sendReliable() { fail() },
448
- async stop() { fail() },
449
- async members() { return [] },
450
- // catalog 只有 reconcile 一个调用点:必须抛错让对账走「目录不可用 → known=undefined」分支。
451
- // 返回 [] 会被当成「目录为空」,把所有 running/waiting 任务误标 orphaned。
452
- async catalog() { fail() },
453
- has() { return false },
454
- releaseWritableRoots() {},
455
- }
456
- }
457
-
458
- export function createDispatcher({ backend = 'subagent', agentTeams, subagents, agent, getAgent, provider = 'spawn', workerPreset, retryDelaysMs, onWarn = noop, onEvent = noop, agents, sessions, sandboxPolicy, onSessionCreated, onAgentCreated, sessionWaitMs }) {
459
- if (backend === 'team') {
460
- // agentTeams 用惰性 getter 传进来时在这里解析:缺服务的 profile 只有选 team 后端才会报错。
461
- const teams = typeof agentTeams === 'function' ? agentTeams() : agentTeams
462
- try {
463
- return createTeamDispatcher({ agentTeams: teams, agent, getAgent, provider, retryDelaysMs, onWarn })
464
- } catch (error) {
465
- // 平台升级把 team 后端废了:插件照常加载(面板与其他能力可用),
466
- // 派发时给同一条可读错误,而不是插件加载失败或抛 is not a function。
467
- const reason = error?.message ?? String(error)
468
- onWarn(`dsh-orchestrator: ${reason}`)
469
- return createUnavailableDispatcher(reason)
470
- }
471
- }
472
- return createSubagentDispatcher({ subagents, agent, getAgent, provider, workerPreset, retryDelaysMs, onWarn, onEvent, agents, sessions, sandboxPolicy, onSessionCreated, onAgentCreated, sessionWaitMs })
473
- }
1
+ // 派发层:把「建子会话 / 投递 / 停止」收成一个窄接口。
2
+ // 后端二选一:subagent(默认,直连 ctx.subagents)或 team(旧的 agentTeams 名册)。
3
+ import { randomUUID } from 'node:crypto'
4
+ import { resolve as resolvePath } from 'node:path'
5
+
6
+ let counter = 0
7
+
8
+ function slugify(title) {
9
+ const ascii = String(title ?? '')
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, '-')
12
+ .replace(/^-+|-+$/g, '')
13
+ counter += 1
14
+ return ascii === '' ? `task-${Date.now()}-${counter}` : `${ascii.slice(0, 32)}-${counter}`
15
+ }
16
+
17
+ const sleep = ms => ms <= 0 ? Promise.resolve() : new Promise(resolve => setTimeout(resolve, ms))
18
+ const noop = () => {}
19
+
20
+ const errorText = error => `${error?.code === undefined ? '' : `[${error.code}] `}${error?.message ?? error}`
21
+
22
+ /** 两个目录是否同一个:Windows 大小写不敏感;解析失败就按原串比较。 */
23
+ export function sameDirectory(left, right) {
24
+ if (typeof left !== 'string' || typeof right !== 'string') return false
25
+ try {
26
+ const a = resolvePath(left)
27
+ const b = resolvePath(right)
28
+ return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
29
+ } catch {
30
+ return left === right
31
+ }
32
+ }
33
+
34
+ // team 后端用到的 agentTeams 成员 API。rc.3 起平台把这组方法整体移除(只剩 state/transact
35
+ // 与团队任务板),调用会报 is not a function;这里按服务方法探测,给出可读错误。
36
+ const TEAM_METHODS = ['spawnTeammate', 'sendMessage', 'interrupt', 'listMembers']
37
+
38
+ export function missingTeamMethods(agentTeams) {
39
+ const source = agentTeams ?? {}
40
+ return TEAM_METHODS.filter(name => typeof source[name] !== 'function')
41
+ }
42
+
43
+ export function teamUnavailableMessage(missing) {
44
+ return `team 后端不可用:当前平台缺少 agentTeams.${missing.join('/')}`
45
+ + '(rc.3 起平台移除了 agentTeams 成员 API),请把 backend 改回 subagent'
46
+ }
47
+
48
+ export function createTeamDispatcher({ agentTeams, agent, getAgent, provider = 'spawn', retryDelaysMs = [1000, 2000, 4000], onWarn = noop }) {
49
+ // 能力探测在构造时做一次:缺方法立即给可读错误,不留到每次派发时报 is not a function。
50
+ const missing = missingTeamMethods(agentTeams)
51
+ if (missing.length > 0) throw new Error(teamUnavailableMessage(missing))
52
+ const currentAgent = () => getAgent === undefined ? agent : getAgent()
53
+ const requireAgent = () => {
54
+ const current = currentAgent()
55
+ if (current === undefined) throw new Error('未找到协调者会话:请配置 coordinatorSession(协调者会话 id),并确保该会话已加载')
56
+ return current
57
+ }
58
+
59
+ return {
60
+ slug(title) {
61
+ return slugify(title)
62
+ },
63
+ async spawn({ name, description, prompt, signal, readOnly }) {
64
+ // team 后端没有按会话设只读的通道:明确拒绝,避免台账标只读、实际可写。
65
+ if (readOnly === true) throw new Error('team 后端不支持只读任务:只读需要 subagent 后端,请把 backend 改回 subagent')
66
+ // 真实 spawnTeammate 要求 provider(fresh 用 'spawn')和 AbortSignal,缺一个都会被拒。
67
+ const input = {
68
+ name: name ?? slugify(String(prompt ?? '').slice(0, 20)),
69
+ description: description ?? 'orchestrator task',
70
+ prompt: [{ type: 'text', text: prompt }],
71
+ context: 'fresh',
72
+ provider,
73
+ ...(signal === undefined ? {} : { signal }),
74
+ }
75
+ const result = await agentTeams.spawnTeammate(requireAgent(), input)
76
+ const member = result.member
77
+ return { id: member.id, name: member.name, sessionId: member.id, target: member.name }
78
+ },
79
+ async send(target, message, signal) {
80
+ return agentTeams.sendMessage(requireAgent(), {
81
+ target,
82
+ content: [{ type: 'text', text: message }],
83
+ ...(signal === undefined ? {} : { signal }),
84
+ })
85
+ },
86
+ /** 续派消息:失败按 retryDelaysMs 重试;全部失败时抛出最后一次原因。 */
87
+ async sendReliable(target, message, signal) {
88
+ let lastError
89
+ for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) {
90
+ try {
91
+ return await this.send(target, message, signal)
92
+ } catch (error) {
93
+ lastError = error
94
+ if (attempt < retryDelaysMs.length) {
95
+ onWarn(`续派第 ${attempt + 1} 次投递失败:${error?.message ?? error}`)
96
+ await sleep(retryDelaysMs[attempt])
97
+ }
98
+ }
99
+ }
100
+ throw new Error(`投递失败:${lastError?.message ?? lastError}`)
101
+ },
102
+ async stop(target) {
103
+ return agentTeams.interrupt(requireAgent(), target)
104
+ },
105
+ members() {
106
+ return agentTeams.listMembers(requireAgent())
107
+ },
108
+ }
109
+ }
110
+
111
+ export function createSubagentDispatcher({ subagents, agent, getAgent, provider = 'spawn', workerPreset, retryDelaysMs = [1000, 2000, 4000], onWarn = noop, onEvent = noop, agents, sessions, sandboxPolicy, onSessionCreated, onAgentCreated, sessionWaitMs = 5000 }) {
112
+ const currentAgent = () => getAgent === undefined ? agent : getAgent()
113
+ const requireAgent = () => {
114
+ const current = currentAgent()
115
+ if (current === undefined) throw new Error('未找到协调者会话:请配置 coordinatorSession(协调者会话 id),并确保该会话已加载')
116
+ return current
117
+ }
118
+ // rc.3 改名 listDescendants ; rc.1 是 listChildren。按服务方法探测,不按版本号猜。
119
+ const listChildren = (parentId, signal) => typeof subagents.listDescendants === 'function'
120
+ ? subagents.listDescendants(parentId, signal)
121
+ : subagents.listChildren(parentId, signal)
122
+ const parentSessionId = () => {
123
+ const current = requireAgent()
124
+ return current.session?.id ?? current.id
125
+ }
126
+ const live = new Map()
127
+ // 只读子任务:派发时把 childId 记下来,agent/created 一到就设沙箱(压首回合窗口)。
128
+ const readOnlyChildren = new Set()
129
+ // 服务按调用时惰性取:插件加载顺序不保证 agents/sessions/sandboxPolicy 已就绪。
130
+ const serviceOf = value => typeof value === 'function' ? value() : value
131
+ // 可写根授权按 targetId → (Session 对象 → (目录 → disposer)) 记:同一会话 resume 前后可能有多个会话对象,
132
+ // 注册只追加不替换;终态/stop 按 targetId 全量释放,旧对象不残留、新对象也不会没人管。
133
+ // 幂等 key 仍是 (Session 对象, 目录):sandboxPolicy 内部按对象身份查 WeakMap,换对象就得重新注册。
134
+ const writableRootGrants = new Map()
135
+ const grantSessions = targetId => {
136
+ let sessions = writableRootGrants.get(targetId)
137
+ if (sessions === undefined) {
138
+ sessions = new Map()
139
+ writableRootGrants.set(targetId, sessions)
140
+ }
141
+ return sessions
142
+ }
143
+ const grantDirs = (targetId, session) => {
144
+ const sessions = grantSessions(targetId)
145
+ let dirs = sessions.get(session)
146
+ if (dirs === undefined) {
147
+ dirs = new Map()
148
+ sessions.set(session, dirs)
149
+ }
150
+ return dirs
151
+ }
152
+ const childSessionOf = childId => {
153
+ const childAgent = serviceOf(agents)?.get?.(childId)
154
+ if (childAgent?.session !== undefined) return childAgent.session
155
+ return serviceOf(sessions)?.get?.(childId)
156
+ }
157
+ // 只读模式:平台唯一写入口就是 session.append('sandbox/mode', { mode:'read-only' })
158
+ // (导出的 setSandboxMode 只是它的薄包装)。只写目标 child session,不动全局默认与父会话。
159
+ // 投影已有 read-only 时跳过:续派/重启 replay 都会保留,不需要重复 append。
160
+ const ensureReadOnly = (childId, sessionHint) => {
161
+ try {
162
+ const session = sessionHint ?? childSessionOf(childId)
163
+ if (session === undefined) return false
164
+ const policy = serviceOf(sandboxPolicy)
165
+ if (policy?.overrideOf?.(session) === 'read-only') return false
166
+ if (typeof session.append !== 'function') return false
167
+ session.append('sandbox/mode', { mode: 'read-only' })
168
+ onEvent('subagent.read-only', { sessionId: childId, detail: { sessionId: session.id ?? childId } })
169
+ return true
170
+ } catch (error) {
171
+ onWarn(`dsh-orchestrator: 设置只读沙箱失败:${errorText(error)}`)
172
+ return false
173
+ }
174
+ }
175
+ // agent/created 早于首个模型请求,能压掉「首回合还没设只读」的窗口。
176
+ if (typeof onAgentCreated === 'function') {
177
+ try {
178
+ onAgentCreated(created => {
179
+ if (created?.id === undefined || !readOnlyChildren.has(created.id)) return
180
+ ensureReadOnly(created.id, created.session)
181
+ })
182
+ } catch (error) {
183
+ onWarn(`dsh-orchestrator: 订阅 agent/created 失败:${errorText(error)}`)
184
+ }
185
+ }
186
+ // 激活返回时子 Session 可能还没进表:等一次 session/created 再设只读,不阻塞派发。
187
+ const ensureReadOnlyReady = childId => {
188
+ if (childSessionOf(childId) !== undefined || typeof onSessionCreated !== 'function') {
189
+ ensureReadOnly(childId)
190
+ return
191
+ }
192
+ void resolveChildSession(childId).then(session => {
193
+ if (session === undefined) {
194
+ onWarn(`dsh-orchestrator: 未拿到子会话 ${childId},只读沙箱未设置`)
195
+ return
196
+ }
197
+ ensureReadOnly(childId, session)
198
+ }).catch(error => onWarn(`dsh-orchestrator: 设置只读沙箱失败:${errorText(error)}`))
199
+ }
200
+ // activation 返回时子 Session 可能还没进会话表:挂一次 session/created 等同一个 childId;
201
+ // 超时只 warn、不重试,也不阻塞派发。判同只看 id,不看 header.cwd(那正是坏的字段)。
202
+ const resolveChildSession = childId => {
203
+ const found = childSessionOf(childId)
204
+ if (found !== undefined) return Promise.resolve(found)
205
+ if (typeof onSessionCreated !== 'function') return Promise.resolve(undefined)
206
+ return new Promise(resolveChild => {
207
+ let done = false
208
+ let unsubscribe
209
+ let timer
210
+ const finish = session => {
211
+ if (done) return
212
+ done = true
213
+ clearTimeout(timer)
214
+ try { unsubscribe?.() } catch { /* 取消订阅失败不影响结果 */ }
215
+ resolveChild(session)
216
+ }
217
+ timer = setTimeout(() => finish(undefined), sessionWaitMs)
218
+ try {
219
+ unsubscribe = onSessionCreated(event => {
220
+ if (event?.id !== childId) return
221
+ finish(childSessionOf(childId) ?? event)
222
+ })
223
+ } catch {
224
+ finish(undefined)
225
+ }
226
+ })
227
+ }
228
+ /** 释放某个 targetId 名下**所有** Session 对象的全部可写根(含 resume 前后的多个对象)。 */
229
+ const releaseWritableRoot = targetId => {
230
+ const sessions = writableRootGrants.get(targetId)
231
+ if (sessions === undefined) return
232
+ writableRootGrants.delete(targetId)
233
+ for (const dirs of sessions.values()) {
234
+ for (const disposer of dirs.values()) {
235
+ try {
236
+ disposer()
237
+ } catch (error) {
238
+ onWarn(`dsh-orchestrator: 释放 target 可写根失败:${errorText(error)}`)
239
+ }
240
+ }
241
+ }
242
+ }
243
+ // rc.3 平台缺陷:子会话 header.cwd 取父会话目录(childSessionMeta),spec.cwd 只记一条 working-directory 事件,
244
+ // 而 sandboxPolicy 的 workspaceRoot 只读 session.header.cwd → workspace-write 下子会话写不进 target。
245
+ // 兜法:给子会话登记 target 为可写根(幂等)。注意 Windows 上只对 harness 文件工具生效,pwsh 进程写仍会被拒。
246
+ // wait=true 只用于派发(子 Session 可能晚几毫秒进表);续派走非等待解析,避免冷恢复时干等超时。
247
+ const ensureWritableRoot = async (childId, target, { wait = false, warnOnMiss = true } = {}) => {
248
+ if (typeof target !== 'string' || target === '') return
249
+ const policy = serviceOf(sandboxPolicy)
250
+ if (policy === undefined || typeof policy.registerWritableRoot !== 'function') return
251
+ const session = wait ? await resolveChildSession(childId) : childSessionOf(childId)
252
+ if (session === undefined) {
253
+ if (warnOnMiss) onWarn(`dsh-orchestrator: 未拿到子会话 ${childId},未登记 target 可写根`)
254
+ return
255
+ }
256
+ // 平台根已经是 target 时不用授权;同一个 (session 对象, 目录) 只注册一次。
257
+ if (sameDirectory(target, session.header?.cwd)) return
258
+ const dirs = grantDirs(childId, session)
259
+ if (dirs.has(target)) return
260
+ try {
261
+ const disposer = policy.registerWritableRoot(session, target)
262
+ if (typeof disposer === 'function') dirs.set(target, disposer)
263
+ // 审计/对表用:sessionId + directory 都带上,能直接和 task_message 投递时间对齐。
264
+ onEvent('subagent.writable-root', { sessionId: childId, detail: { directory: target } })
265
+ } catch (error) {
266
+ onWarn(`dsh-orchestrator: 登记 target 可写根失败:${errorText(error)}`)
267
+ }
268
+ }
269
+
270
+ // delivery: 'caller' 时没人 await 会变成未处理拒绝,这里必须挂上;同时把 settle 抛给上层驱动队列。
271
+ // 回调里的异常(日志/事件消费方抛错)也不能变成宿主层面的未处理拒绝。
272
+ // 注意:settle 只是这一轮结束(continuable 会话还会续派),**不释放**可写根;释放留给 stop/终态清理。
273
+ const track = activation => {
274
+ live.set(activation.childId, activation)
275
+ activation.result.then(
276
+ result => {
277
+ try {
278
+ live.delete(activation.childId)
279
+ readOnlyChildren.delete(activation.childId)
280
+ onEvent('subagent.settled', {
281
+ sessionId: activation.childId,
282
+ detail: { stopReason: result?.stopReason ?? null },
283
+ })
284
+ } catch { /* 结算事件失败不能外溢 */ }
285
+ },
286
+ error => {
287
+ try {
288
+ live.delete(activation.childId)
289
+ readOnlyChildren.delete(activation.childId)
290
+ onEvent('subagent.failed', { sessionId: activation.childId, detail: { error: errorText(error) } })
291
+ } catch { /* 结算事件失败不能外溢 */ }
292
+ },
293
+ ).catch(() => {})
294
+ return activation
295
+ }
296
+
297
+ return {
298
+ slug(title) {
299
+ return slugify(title)
300
+ },
301
+ async spawn({ name, label, prompt, signal, taskId, cwd, readOnly }) {
302
+ const parent = requireAgent()
303
+ // agent 已创建但 session 还没挂上时,平台 startActivation 会在 parent.session 上取 .id 抛 TypeError;
304
+ // 这里先给可读错误,并由 queue 当成可重试(等会话就绪)。
305
+ if (parent?.session === undefined || parent.session === null) {
306
+ throw new Error('协调者会话尚未就绪(agent.session 为空),稍后重试')
307
+ }
308
+ if (parent.id === undefined) throw new Error('协调者会话缺少 id,稍后重试')
309
+ const childId = randomUUID()
310
+ // 只读任务:在开始创建前记下 childId,agent/created 时立刻设沙箱;激活返回后再补一次幂等设置。
311
+ if (readOnly === true) readOnlyChildren.add(childId)
312
+ // 目标目录优先;没给就沿用父会话 cwd(保持旧行为)。
313
+ const baseCwd = cwd ?? parent.session?.header?.cwd
314
+ const activationPrompt = [{ type: 'text', text: String(prompt ?? '') }]
315
+ // 子会话显式用 worker 预设,绕开 composeFrom 继承协调者的 preset restrict(探针 §1)。
316
+ // 只有 spawn provider 支持 agentPreset;fork 没有该能力,带上去会被平台拒绝(UNSUPPORTED_CAPABILITY)。
317
+ const agentPreset = provider === 'spawn' ? { agentPreset: workerPreset ?? 'dsh-orchestrator-worker' } : {}
318
+ const location = baseCwd === undefined ? {} : { cwd: baseCwd }
319
+ const base = {
320
+ provider,
321
+ label: label ?? name ?? slugify(String(prompt ?? '').slice(0, 20)),
322
+ delivery: 'caller',
323
+ signal: signal ?? new AbortController().signal,
324
+ childId,
325
+ }
326
+ // DSH 0.2.2-rc.3(当前桌面)的 startActivation 是扁平 spec:spec.parent/spec.prompt/…
327
+ const spec = { ...base, parent, prompt: activationPrompt, ...agentPreset, ...location }
328
+ // rc.1 及更早:旧平台读 spec.request.*,扁平字段会被忽略。
329
+ const legacySpec = {
330
+ ...base,
331
+ request: { parent, prompt: activationPrompt, ...agentPreset, ...location },
332
+ }
333
+ const shapeError = error => /reading '(parent|request)'|spec\.(parent|request)|request\.parent/.test(error?.message ?? '')
334
+ let activation
335
+ onEvent('subagent.spawn.requested', { taskId, detail: { childId, label: spec.label, provider } })
336
+ try {
337
+ try {
338
+ activation = await subagents.startActivation(spec)
339
+ } catch (error) {
340
+ if (!shapeError(error)) throw error
341
+ // 升级前的旧安装:扁平 spec 读不到 request.parent,回退一次旧包装。
342
+ onEvent('subagent.spawn.legacy-spec', { taskId, detail: { childId, error: error?.message ?? String(error) } })
343
+ activation = await subagents.startActivation(legacySpec)
344
+ }
345
+ } catch (error) {
346
+ // 两次都失败:清掉只读登记,别攒废弃 UUID。
347
+ readOnlyChildren.delete(childId)
348
+ throw error
349
+ }
350
+ onEvent('subagent.spawn.accepted', {
351
+ taskId,
352
+ sessionId: activation.childId,
353
+ detail: { childId: activation.childId, messageId: activation.messageId ?? null, provider },
354
+ })
355
+ track(activation)
356
+ if (readOnly === true) ensureReadOnlyReady(activation.childId)
357
+ // 目标目录才需要补可写根;只读任务绝不能注册可写根(否则「只读」有后门)。
358
+ if (readOnly !== true && typeof cwd === 'string' && cwd !== '') {
359
+ void ensureWritableRoot(activation.childId, cwd, { wait: true }).catch(error => {
360
+ onWarn(`dsh-orchestrator: 登记 target 可写根失败:${errorText(error)}`)
361
+ })
362
+ }
363
+ return { id: activation.childId, name: spec.label, sessionId: activation.childId, target: activation.childId }
364
+ },
365
+ async send(target, message, signal, targetDir, taskId, readOnly) {
366
+ const sent = await subagents.sendMessage(requireAgent(), target, [{ type: 'text', text: message }], {
367
+ signal: signal ?? new AbortController().signal,
368
+ })
369
+ // 投递成功先记事件(代码顺序:投递 → 注册),日志时间序可自证「注册在投递之后」。
370
+ try {
371
+ onEvent('task.message.sent', { taskId, sessionId: target, detail: { targetDir: targetDir ?? null } })
372
+ } catch { /* 审计事件失败不能外溢 */ }
373
+ // 只读任务续派:resume 后按同一会话日志仍应保留 read-only;这里幂等补一次(已有则跳过)。
374
+ if (readOnly === true) ensureReadOnly(target)
375
+ // 冷恢复/续派时子会话在发送前还取不到,sendMessage 之后才 resolve 出**新的 Session 对象**;
376
+ // 投递成功后再幂等补注册(失败路径不注册;失败只 warn,不影响投递结果)。
377
+ if (readOnly !== true) {
378
+ try {
379
+ await ensureWritableRoot(target, targetDir)
380
+ } catch (error) {
381
+ onWarn(`dsh-orchestrator: 续派后补注册可写根失败:${errorText(error)}`)
382
+ }
383
+ }
384
+ return sent
385
+ },
386
+ async sendReliable(target, message, signal, targetDir, taskId, readOnly) {
387
+ let lastError
388
+ for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) {
389
+ try {
390
+ return await this.send(target, message, signal, targetDir, taskId, readOnly)
391
+ } catch (error) {
392
+ lastError = error
393
+ if (attempt < retryDelaysMs.length) {
394
+ onWarn(`续派第 ${attempt + 1} 次投递失败:${error?.message ?? error}`)
395
+ await sleep(retryDelaysMs[attempt])
396
+ }
397
+ }
398
+ }
399
+ throw new Error(`投递失败:${lastError?.message ?? lastError}`)
400
+ },
401
+ /** 有本地 activation 直接 dispose(提前取消);没有(重启后)走 interrupt。两种都释放可写根。 */
402
+ async stop(target) {
403
+ try {
404
+ const activation = live.get(target)
405
+ if (activation !== undefined) {
406
+ await activation.dispose()
407
+ live.delete(target)
408
+ return
409
+ }
410
+ // rc.3:interrupt(targetSessionId, authority),authority 只认 { kind: 'ancestor' | 'user' }。
411
+ // task_stop 是用户动作,按平台 interruptByUser 的写法传 { kind: 'user' };裸 agent 不在契约里。
412
+ // rc.1:interruptByParent(childId, parentSessionId, mode)。
413
+ if (typeof subagents.interrupt === 'function') return subagents.interrupt(target, { kind: 'user' })
414
+ return subagents.interruptByParent(target, parentSessionId(), 'continuable')
415
+ } finally {
416
+ releaseWritableRoot(target)
417
+ }
418
+ },
419
+ /** 任务进入终态(done/cancelled/orphaned/failed)时由上层调用,释放该会话的可写根。 */
420
+ releaseWritableRoots(target) {
421
+ releaseWritableRoot(target)
422
+ },
423
+ members() {
424
+ return listChildren(parentSessionId()).catch(error => {
425
+ onWarn(`子会话目录读取失败:${errorText(error)}`)
426
+ return []
427
+ })
428
+ },
429
+ catalog() {
430
+ return listChildren(parentSessionId())
431
+ },
432
+ has(target) {
433
+ return live.has(target)
434
+ },
435
+ }
436
+ }
437
+
438
+ // 后端不可用时的占位派发器:派发类调用返回可读错误,目录类查询降级为空(不打断面板/孤儿巡检)。
439
+ function createUnavailableDispatcher(reason) {
440
+ const fail = () => { throw new Error(reason) }
441
+ return {
442
+ slug(title) {
443
+ return slugify(title)
444
+ },
445
+ async spawn() { fail() },
446
+ async send() { fail() },
447
+ async sendReliable() { fail() },
448
+ async stop() { fail() },
449
+ async members() { return [] },
450
+ // catalog 只有 reconcile 一个调用点:必须抛错让对账走「目录不可用 → known=undefined」分支。
451
+ // 返回 [] 会被当成「目录为空」,把所有 running/waiting 任务误标 orphaned。
452
+ async catalog() { fail() },
453
+ has() { return false },
454
+ releaseWritableRoots() {},
455
+ }
456
+ }
457
+
458
+ export function createDispatcher({ backend = 'subagent', agentTeams, subagents, agent, getAgent, provider = 'spawn', workerPreset, retryDelaysMs, onWarn = noop, onEvent = noop, agents, sessions, sandboxPolicy, onSessionCreated, onAgentCreated, sessionWaitMs }) {
459
+ if (backend === 'team') {
460
+ // agentTeams 用惰性 getter 传进来时在这里解析:缺服务的 profile 只有选 team 后端才会报错。
461
+ const teams = typeof agentTeams === 'function' ? agentTeams() : agentTeams
462
+ try {
463
+ return createTeamDispatcher({ agentTeams: teams, agent, getAgent, provider, retryDelaysMs, onWarn })
464
+ } catch (error) {
465
+ // 平台升级把 team 后端废了:插件照常加载(面板与其他能力可用),
466
+ // 派发时给同一条可读错误,而不是插件加载失败或抛 is not a function。
467
+ const reason = error?.message ?? String(error)
468
+ onWarn(`dsh-orchestrator: ${reason}`)
469
+ return createUnavailableDispatcher(reason)
470
+ }
471
+ }
472
+ return createSubagentDispatcher({ subagents, agent, getAgent, provider, workerPreset, retryDelaysMs, onWarn, onEvent, agents, sessions, sandboxPolicy, onSessionCreated, onAgentCreated, sessionWaitMs })
473
+ }