dsh-agent-foreman 0.0.0-stage → 0.2.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/lib/team.js ADDED
@@ -0,0 +1,1102 @@
1
+ import { readSubagentProviders } from './catalog.js';
2
+
3
+ /** 从 ContentBlock 数组取纯文本(与 engine.js 的 extractText 同逻辑,避免循环依赖)。 */
4
+ function extractText(output) {
5
+ if (!Array.isArray(output))
6
+ return '';
7
+ return output
8
+ .filter(part => part !== null && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string')
9
+ .map(part => part.text)
10
+ .join('\n')
11
+ .trim();
12
+ }
13
+
14
+ /** 把激活错误翻译成可读文本(与 engine.js 的 explainActivationError 同逻辑)。 */
15
+ function explainActivationError(error) {
16
+ const message = error instanceof Error ? error.message : String(error);
17
+ if (/maxDepth/i.test(message))
18
+ return `${message}(提示:调用方自己就是子智能体,或策略里的最大嵌套层数太小)`;
19
+ if (/ACTIVATION_LIMIT_REACHED|maxActiveSubagents/i.test(message))
20
+ return `${message}(提示:并行子智能体已达宿主上限,调低 concurrency 或分两轮派单)`;
21
+ return message;
22
+ }
23
+ /**
24
+ * 团队模式:把承包商做成宿主的**智能体团队**成员来派单。
25
+ *
26
+ * 为什么要有这条路:普通模式走 `subagents.startActivation`,子智能体只在
27
+ * 父会话的「子智能体」计数里出现,模型靠 `agentOptions` 在会话创建时就定死。
28
+ * 用户要的是成员出现在「智能体团队」区、并且能自己换模型——那就得走团队服务:
29
+ *
30
+ * 1. `agentTeams.spawnTeammate(lead, member)` 建一个真成员(它自己的会话);
31
+ * 2. `sessions.selectModel(...)` 把成员会话的模型换成承包商配的那套;
32
+ * 3. `agentTeams.sendMessage(lead, { target, content })` 把工单当团队消息发过去;
33
+ * 4. 等它那一轮跑完,从它的会话事件里把交付正文捞回来。
34
+ *
35
+ * 这套依赖宿主三个服务,任何一步不可用都**不抛给用户**:`dispatch()` 返回
36
+ * `{ ok: false, reason }`,由引擎退回普通模式(见 `lib/engine.js` 的 `#runOrder`)。
37
+ */
38
+
39
+ /** 成员首次进场只回一句话,真正的工单随后用消息发,避免开局就跑一个大任务。 */
40
+ const HANDSHAKE = '待命。收到工单后用你的工具干活,干完把结论写在回复里;不要反问,不要等确认。';
41
+
42
+ const WAIT_STEP_MS = 400;
43
+ /** 等成员空闲(好切模型)的上限。 */
44
+ const IDLE_TIMEOUT_MS = 120000;
45
+ /**
46
+ * 切模型前等空闲的**硬上限**。
47
+ *
48
+ * 不管调用方给多大窗口,都不许等满 `IDLE_TIMEOUT_MS`(2 分钟)——那会让一份已经
49
+ * 干完的活被判失败。这里设成能覆盖"新建成员跑完握手"的量级,再大就没有收益了。
50
+ */
51
+ const IDLE_WAIT_BUDGET_MS = 20000;
52
+ /**
53
+ * 复用已有成员时,为了切模型而等它空闲的窗口。
54
+ *
55
+ * 刻意很短:复用成员时它可能正被上一单占着,等它空下来是下一轮的事——
56
+ * 这一轮直接用当前模型跑完更划算,不该为了切模型拖慢派单。
57
+ */
58
+ const IDLE_QUICK_MS = 600;
59
+ /**
60
+ * 新建成员时等它跑完握手、再切模型的窗口。
61
+ *
62
+ * 比复用窗口宽得多,因为这是**一次性投入**:成员刚建出来必定在跑握手那一轮,
63
+ * 宿主 `subagent routing` 持有它的会话,此刻 `selectModel` 一定被
64
+ * `session/agent-busy` 拒绝。等握手结束(ownership 释放)再切,这个成员
65
+ * 后面每一单都能用上名册配的模型;等不到就只是这一轮退化成父会话模型。
66
+ */
67
+ const NEW_MEMBER_IDLE_MS = 15000;
68
+ /**
69
+ * 等成员 phase 变 active 的上限。
70
+ *
71
+ * 宿主 `spawnTeammate` 只是把成员建出来,它还要走 provisioning(接受初始
72
+ * prompt)才会变 active;`sendMessage` 在 provisioning 期间会抛
73
+ * `active teammate "worker" not found`。所以派单前必须等到这一步真的过去。
74
+ */
75
+ export const ACTIVE_TIMEOUT_MS = 120000;
76
+ /** `waitActive` 轮询名册的间隔(比整体步长小:provisioning 通常只持续一两秒)。 */
77
+ const ACTIVE_STEP_MS = 200;
78
+
79
+ /**
80
+ * 从成员行里取它实际在跑的模型(宿主由 `live.options.model` 提供)。
81
+ * @returns 模型 id,读不到时为空串。
82
+ */
83
+ function actualModelOf(row) {
84
+ if (row === null || row === undefined)
85
+ return '';
86
+ const candidates = [row.model, row.live?.options?.model, row.live?.model, row.options?.model];
87
+ for (const candidate of candidates) {
88
+ if (typeof candidate === 'string' && candidate !== '')
89
+ return candidate;
90
+ }
91
+ return '';
92
+ }
93
+
94
+ /** 把失败成员行的 diagnostics[0] 压成一句话。 */
95
+ function failedReasonOf(row) {
96
+ const first = Array.isArray(row?.diagnostics) ? row.diagnostics[0] : undefined;
97
+ const detail = typeof first === 'string'
98
+ ? first
99
+ : first?.message ?? first?.reason ?? first?.detail ?? (first === undefined ? '' : String(first));
100
+ const name = typeof row?.name === 'string' && row.name !== '' ? row.name : '成员';
101
+ return `${name} 上线失败${detail === '' ? '' : `:${String(detail).slice(0, 200)}`}`;
102
+ }
103
+
104
+ /**
105
+ * note 里那句「实际模型」。
106
+ *
107
+ * 名册为空表示跟随父会话,此时没有可比对的目标,不报不一致;名册配了却又
108
+ * 和宿主回显的不一样(切模型失败 / provider 目录不认),才点明不一致。
109
+ */
110
+ function modelNoteOf(member, actualModel) {
111
+ if (actualModel === '')
112
+ return '';
113
+ const configured = typeof member?.model === 'string' ? member.model : '';
114
+ if (configured !== '' && configured !== actualModel)
115
+ return `实际模型 ${actualModel}(名册配 ${configured},不一致)`;
116
+ return `实际模型 ${actualModel}`;
117
+ }
118
+
119
+ /** 从 ContentBlock 数组里取纯文本(不依赖 engine,避免循环引用)。 */
120
+ function contentText(blocks) {
121
+ if (!Array.isArray(blocks))
122
+ return '';
123
+ return blocks
124
+ .filter(block => block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
125
+ .map(block => block.text)
126
+ .join('\n')
127
+ .trim();
128
+ }
129
+ const START_TIMEOUT_MS = 90_000;
130
+
131
+ /**
132
+ * 取宿主的 remote 命名空间。
133
+ *
134
+ * cordis 里没 inject 的作用域直接读 `ctx.remote` 会抛
135
+ * `cannot get property "remote" without inject`,所以拿不到就当没有。
136
+ */
137
+ function remoteSessionOf(ctx) {
138
+ for (const scope of [ctx, ctx?.root]) {
139
+ try {
140
+ const session = scope?.remote?.session;
141
+ if (session !== undefined && session !== null)
142
+ return session;
143
+ }
144
+ catch {
145
+ // 没 inject,换下一个作用域。
146
+ }
147
+ }
148
+ return undefined;
149
+ }
150
+
151
+
152
+ /**
153
+ * 把名册里的名字转成宿主接受的团队成员名。
154
+ *
155
+ * 宿主要求 `lower-kebab-case`(≤64 字符、不能叫 lead),中文名直接用不了,
156
+ * 所以默认统一叫 `worker`,第二个起 `worker-2`、`worker-3`…;纯 ASCII 的名字
157
+ * 仍按自己的名字来,中文名照旧留在成员的 description 里给人看。
158
+ *
159
+ * @param member - 名册成员。
160
+ * @param taken - 已经被占用的成员名集合(同一进程里复用)。
161
+ * @returns 成员名。
162
+ */
163
+ function teammateName(member, taken) {
164
+ // 用 `member.id`(稳定唯一)而非 `name` 生成团队名:
165
+ // 所有中文名走 `name` 都会落成 'worker',导致不同承包商被映射到**同一个**
166
+ // 团队成员——新工人复用了力工的旧成员,模型拦截注入也就永远不触发。
167
+ // 用 id 生成后,每个承包商有自己独立的团队成员(各记各的跨轮记忆、各锁各的模型)。
168
+ const raw = String(member?.id ?? member?.name ?? '');
169
+ const ascii = raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 36);
170
+ let candidate = ascii !== '' && ascii !== 'lead' && /^[a-z]/.test(ascii) ? ascii : '';
171
+ if (candidate === '') {
172
+ // 中文 id:取一个稳定短哈希,保证不同 id 得到不同团队名、且跨轮可复用。
173
+ let h = 0;
174
+ for (const ch of raw)
175
+ h = ((h * 31 + ch.codePointAt(0)) >>> 0);
176
+ candidate = `worker-${h.toString(36)}`;
177
+ }
178
+ if (!taken.has(candidate)) {
179
+ taken.add(candidate);
180
+ return candidate;
181
+ }
182
+ for (let index = 2; index < 100; index += 1) {
183
+ const next = `${candidate}-${index}`;
184
+ if (!taken.has(next)) {
185
+ taken.add(next);
186
+ return next;
187
+ }
188
+ }
189
+ return `${candidate}-${taken.size + 1}`;
190
+ }
191
+
192
+ /**
193
+ * @param args.ctx - 插件上下文(用来 `ctx.get(...)` 取宿主服务)。
194
+ * @param args.logger - 可选的宿主 logger。
195
+ */
196
+ export function createTeamRunner({ ctx, logger, activeTimeoutMs = ACTIVE_TIMEOUT_MS }) {
197
+ /** 合同商 id → 团队成员(同一轮里复用,避免每张单都新开成员)。 */
198
+ const members = new Map();
199
+ /** 已经用掉的成员名,保证 `worker` / `worker-2` 不撞车。 */
200
+ const usedNames = new Set();
201
+
202
+ /**
203
+ * 找宿主的团队服务。
204
+ *
205
+ * 插件自己的 ctx 是个旁支作用域,团队服务注册在别的作用域里时这里取不到,
206
+ * 所以要顺着 `ctx.root` 再找一遍(两处都试,拿到哪个用哪个)。
207
+ *
208
+ * @returns 团队服务,找不到时为 undefined。
209
+ */
210
+ const findService = () => {
211
+ for (const scope of [ctx, ctx?.root]) {
212
+ try {
213
+ const teams = scope?.get?.('agentTeams');
214
+ if (teams !== undefined && teams !== null)
215
+ return teams;
216
+ }
217
+ catch {
218
+ // 作用域不支持 get 时继续找下一个。
219
+ }
220
+ }
221
+ return undefined;
222
+ };
223
+ const service = () => {
224
+ const teams = findService();
225
+ if (teams === undefined)
226
+ throw new TeamUnavailable('宿主没有 agentTeams 服务(未装智能体团队插件)');
227
+ return teams;
228
+ };
229
+
230
+ /**
231
+ * 确保承包商已经是团队里的成员。
232
+ *
233
+ * @returns 成员条目 `{ sessionId, name, switchStatus, switchDetail, actualModel }`。
234
+ * @throws MemberNotReady 成员没有变成 active(或直接失败),调用方据此退回普通模式。
235
+ */
236
+ async function ensureMember({ member, signal, parent }) {
237
+ const cached = members.get(member.id);
238
+ if (cached !== undefined) {
239
+ const row = await waitActive({ teams: service(), parent, name: cached.name, signal });
240
+ if (row !== undefined)
241
+ cached.actualModel = actualModelOf(row);
242
+ return cached;
243
+ }
244
+ const teams = service();
245
+ const displayName = teammateName(member, usedNames);
246
+ // 团队名册是持久化的:上次会话里建过的成员还在,再 spawn 同名会被拒
247
+ // (teammate name "worker" was already used in this Team)。所以先认领旧的。
248
+ const existing = await findExistingMember({ teams, parent, name: displayName });
249
+ if (existing !== undefined) {
250
+ // 认领到的旧成员也可能刚被别人 spawn 出来、phase 还没 active。
251
+ await waitActive({ teams, parent, name: existing.name, signal });
252
+ // 复用分支用短窗口:这个成员上一单可能刚跑完,此刻切模型大概率成功;
253
+ // 万一还占着,就别为它拖慢本轮——下一单再切。
254
+ const idle = await waitIdle(existing.sessionId, IDLE_QUICK_MS);
255
+ const switched = idle === true
256
+ ? await switchModel(existing.sessionId, member)
257
+ : { status: 'skipped', detail: '成员正忙,本轮跳过切模型(跟随父会话)' };
258
+ const entry = {
259
+ sessionId: existing.sessionId,
260
+ name: existing.name,
261
+ switchStatus: switched.status,
262
+ switchDetail: switched.detail,
263
+ actualModel: actualModelOf(readMemberRow({ teams, parent, name: existing.name })),
264
+ };
265
+ members.set(member.id, entry);
266
+ return entry;
267
+ }
268
+ // —— 模型拦截注入 ——
269
+ //
270
+ // spawnTeammate 的请求里没有 model 字段,成员只能继承父会话模型;事后
271
+ // selectModel 又被 `session/agent-busy: owned by subagent routing` 拒绝
272
+ // (常驻子智能体会话被路由层持锁)。这就是团队模式"分配不了模型"的根因。
273
+ //
274
+ // 解法:spawnTeammate 内部正是调 `ctx.subagents.startActivation({ request:
275
+ // { prompt, parent } })`。我们在它调用期间**临时拦截** startActivation,把
276
+ // 名册的 provider/model/reasoningEffort 注入 `request.agentOptions`——
277
+ // 成员创建时就锁定正确模型,同时保留团队成员身份(团队面板可见)。
278
+ // 这正是"先拦截再分发",两头兼得。
279
+ const agentOptions = (member.provider !== '' && member.model !== '')
280
+ ? {
281
+ provider: member.provider,
282
+ model: member.model,
283
+ ...(member.reasoningEffort !== '' ? { reasoningEffort: member.reasoningEffort } : {}),
284
+ }
285
+ : undefined;
286
+ const subagents = ctx.get('subagents') ?? ctx.root?.get?.('subagents');
287
+ const originalStart = (typeof subagents?.startActivation === 'function')
288
+ ? subagents.startActivation
289
+ : undefined;
290
+ if (originalStart !== undefined && agentOptions !== undefined) {
291
+ subagents.startActivation = function interceptedStartActivation(spec) {
292
+ // 只在 spawnTeammate 那种"没有显式 agentOptions"的内部调用上注入,
293
+ // 不覆盖别人显式指定的路由。
294
+ if (spec?.request && spec.request.agentOptions === undefined)
295
+ spec.request.agentOptions = agentOptions;
296
+ return originalStart.call(this, spec);
297
+ };
298
+ }
299
+ let created;
300
+ try {
301
+ created = await teams.spawnTeammate(parent, {
302
+ name: displayName,
303
+ description: `${member.name} · ${member.provider || '跟随当前会话'}/${member.model || '默认模型'}:${member.prompt === '' ? '按工单干活' : member.prompt.slice(0, 80)}`,
304
+ provider: teamProvider(ctx),
305
+ context: 'fresh',
306
+ prompt: [{ type: 'text', text: HANDSHAKE }],
307
+ signal,
308
+ });
309
+ }
310
+ finally {
311
+ // 无论成败都还原,绝不影响后续子智能体创建。
312
+ if (originalStart !== undefined && subagents.startActivation !== originalStart)
313
+ subagents.startActivation = originalStart;
314
+ }
315
+ // memberView 的 `id` 就是成员会话的 id(`sessionId` 字段并不存在)。
316
+ const sessionId = created?.member?.id;
317
+ if (typeof sessionId !== 'string' || sessionId === '')
318
+ throw new TeamUnavailable('spawnTeammate 没返回成员会话');
319
+ // 投递只认“成员名”,所以名字也要记住。
320
+ const name = typeof created?.member?.name === 'string' ? created.member.name : displayName;
321
+ // spawn 只是把成员建出来,它还要走 provisioning(接受初始 prompt)才 active。
322
+ // 在这之前 sendMessage 会以 `active teammate "x" not found` 拒绝——必须先等。
323
+ await waitActive({ teams, parent, name, signal });
324
+ // 这里是**新建成员**,模型要用很多单,值得多等一会儿:
325
+ // 成员刚进场在跑握手那一轮,`subagent routing` 持有它的会话,此刻
326
+ // selectModel 必然以 `session/agent-busy: ... owned by subagent routing`
327
+ // 被拒。等握手跑完(ownership 释放)再切,第一单就能用上名册的模型。
328
+ // 判定用事件(握手轮会落一条 assistant 正文),不依赖会滞后的宿主状态;
329
+ // 已有的成员走上面那条复用分支,不会每次都付这个等待。
330
+ const ready = await waitHandshakeDone(sessionId, signal);
331
+ const switched = ready === true
332
+ ? await switchModel(sessionId, member)
333
+ : { status: 'skipped', detail: '握手轮次未结束,本轮暂用父会话模型' };
334
+ const entry = {
335
+ sessionId,
336
+ name,
337
+ switchStatus: switched.status,
338
+ handshakeNote: ready === true
339
+ ? `握手已就绪,切模型=${switched.status}`
340
+ : '握手未确认(窗口内没看到握手正文),跳过切模型',
341
+ switchDetail: switched.detail,
342
+ actualModel: actualModelOf(readMemberRow({ teams, parent, name })),
343
+ };
344
+ members.set(member.id, entry);
345
+ return entry;
346
+ }
347
+
348
+ /**
349
+ * 从名册里取这个成员那一行。
350
+ * @returns 成员行,宿主要么没有 listMembers、要么查不到时为 undefined。
351
+ */
352
+ function readMemberRow({ teams, parent, name }) {
353
+ if (typeof teams?.listMembers !== 'function')
354
+ return undefined;
355
+ try {
356
+ const rows = teams.listMembers(parent);
357
+ const list = Array.isArray(rows) ? rows : [];
358
+ return list.find(candidate => candidate?.name === name && candidate?.role === 'teammate');
359
+ }
360
+ catch {
361
+ return undefined;
362
+ }
363
+ }
364
+
365
+ /**
366
+ * 等成员的 phase 变成 active(名册上 `status !== 'provisioning'`)。
367
+ *
368
+ * 宿主的 `sendMessage` 只认 active 成员:`spawnTeammate` 返回时成员还在
369
+ * provisioning,直接发消息会撞进间隙并抛 `active teammate "x" not found`。
370
+ *
371
+ * @returns 成员行;宿主没有 `listMembers` 时返回 undefined(降级成原有时序)。
372
+ * @throws MemberNotReady 成员失败或超时还在 provisioning。
373
+ */
374
+ async function waitActive({ teams, parent, name, signal, timeoutMs = activeTimeoutMs }) {
375
+ if (typeof teams?.listMembers !== 'function') {
376
+ // 降级:宿主没有名册接口,没法轮询 status,睡一小段再按原来的路走。
377
+ await new Promise(resolve => setTimeout(resolve, Math.min(ACTIVE_STEP_MS, Math.max(20, timeoutMs))));
378
+ return undefined;
379
+ }
380
+ const step = Math.min(ACTIVE_STEP_MS, Math.max(20, timeoutMs));
381
+ const startedAt = Date.now();
382
+ for (;;) {
383
+ const row = readMemberRow({ teams, parent, name });
384
+ if (row !== undefined) {
385
+ if (row.status === 'failed')
386
+ throw new MemberNotReady(failedReasonOf(row));
387
+ if (row.status !== 'provisioning')
388
+ return row;
389
+ }
390
+ if (signal?.aborted === true)
391
+ throw new MemberNotReady('派单被取消');
392
+ if (Date.now() - startedAt > timeoutMs)
393
+ throw new MemberNotReady('成员一直停在 provisioning');
394
+ await new Promise(resolve => setTimeout(resolve, step));
395
+ }
396
+ }
397
+
398
+ /**
399
+ * 团队里是否已经有这个成员(上一个会话建的也算)。
400
+ * @returns `{ sessionId, name }`,没有时 undefined。
401
+ */
402
+ async function findExistingMember({ teams, parent, name }) {
403
+ if (typeof teams.listMembers !== 'function')
404
+ return undefined;
405
+ try {
406
+ const rows = teams.listMembers(parent);
407
+ const list = Array.isArray(rows) ? rows : [];
408
+ const row = list.find(candidate => candidate?.name === name && candidate?.role === 'teammate');
409
+ if (row === undefined || typeof row.id !== 'string' || row.id === '')
410
+ return undefined;
411
+ if (row.status === 'failed' || row.status === 'provisioning')
412
+ return undefined;
413
+ return { sessionId: row.id, name: row.name };
414
+ }
415
+ catch (error) {
416
+ logger?.warn?.(`dsh-agent-foreman: 读团队名册失败:${errorMessageOf(error)}`);
417
+ return undefined;
418
+ }
419
+ }
420
+
421
+ /**
422
+ * 等成员这一轮跑完(停止在等下一句)。
423
+ *
424
+ * 宿主的 `sessions.selectModel` 在 agent 忙时会拒绝(`session/agent-busy`),
425
+ * 所以切模型前必须先把它等到空闲。等不到也不算错——切模型本来就可能失败,
426
+ * 只是会退化成「跟着当前会话的模型」。
427
+ */
428
+ async function waitIdle(sessionId, timeoutMs = IDLE_TIMEOUT_MS) {
429
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
430
+ const agents = ctx.get('agents') ?? ctx.root?.get?.('agents');
431
+ const startedAt = Date.now();
432
+ // 这个等待只服务于「切模型前别撞上 agent-busy」,它**不是**本轮收活的判定。
433
+ // 调用方按场景给窗口:新建成员值得多等(模型要用很多单),复用成员几乎不等。
434
+ // 但无论哪个场景都有硬上限,绝不能等满整单预算——那会让一份已经干完的活
435
+ // 被判失败。等不到就往下走:切模型失败只是退化成继承父会话,不算错。
436
+ const budget = Math.min(timeoutMs, IDLE_WAIT_BUDGET_MS);
437
+ for (;;) {
438
+ let status;
439
+ try {
440
+ const agent = agents?.get?.(sessionId);
441
+ status = agent?.status ?? agent?.state;
442
+ }
443
+ catch {
444
+ status = undefined;
445
+ }
446
+ if (status !== 'running')
447
+ return true;
448
+ if (Date.now() - startedAt > budget)
449
+ return false;
450
+ await sleep(WAIT_STEP_MS);
451
+ }
452
+ }
453
+
454
+ /**
455
+ * 等新建成员的**握手轮**跑完,好给它切模型。
456
+ *
457
+ * 判定刻意用事件而不是宿主状态:状态可能长期停在 running(刷新滞后、
458
+ * 或该 agent 被路由占着),只看状态会一直等满窗口。而握手轮结束时,
459
+ * 会话里必然多出一条 assistant/message——那是不可逆的事实。
460
+ *
461
+ * @returns 握手足矣判断为"跑完了"就 true;窗口内没看到就 false。
462
+ */
463
+ async function waitHandshakeDone(sessionId, signal, timeoutMs = NEW_MEMBER_IDLE_MS) {
464
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
465
+ const startedAt = Date.now();
466
+ // 先探一次"可观测性":会话事件与宿主状态两条通道都读不到时,再等也只是
467
+ // 干耗窗口(15 秒起步),而且拿不到任何证据。这种情况下直接认输,让本单
468
+ // 用父会话模型跑——慢一点没关系,但绝不能无谓地烧掉整单预算。
469
+ if ((await sessionEventSummary(sessionId)) === undefined && agentStatusOf(sessionId) === undefined)
470
+ return false;
471
+ for (;;) {
472
+ if (signal?.aborted === true)
473
+ return false;
474
+ // 必须等**握手回复落地**(出现 assistant 正文),不能拿状态短路:
475
+ // 成员在被投递工单前就是 idle,若拿"状态非 running"当完成信号,
476
+ // 会在握手回复到达前就继续往下走,导致 baseline 早于握手回复,
477
+ // 那条握手回复(例如"共享任务板上还没有工单")会被误当成本单产物
478
+ // 交回去——线上实测正是这样交回一份答非所问的正文。
479
+ const now = await sessionEventSummary(sessionId);
480
+ if (now !== undefined && now.assistant > 0)
481
+ return true;
482
+ if (now === undefined) {
483
+ // 事件读不到时才退化成看状态:这种情况下拿不到握手证据,
484
+ // 只能假设它跑完了,让上层按超时兜底。
485
+ const status = agentStatusOf(sessionId);
486
+ if (status !== undefined && status !== 'running')
487
+ return true;
488
+ }
489
+ if (Date.now() - startedAt > Math.min(timeoutMs, IDLE_WAIT_BUDGET_MS))
490
+ return false;
491
+ await sleep(WAIT_STEP_MS);
492
+ }
493
+ }
494
+
495
+ /**
496
+ * 会话里各类事件的数量快照。
497
+ * @returns `{ total, assistant }`;会话读不到时 undefined。
498
+ */
499
+ async function sessionEventSummary(sessionId) {
500
+ // "读不到会话"与"会话存在但事件为空"必须分开:
501
+ // 新建成员刚 spawn 出来时事件就是空的,那是合法状态;只有连会话对象
502
+ // 本身都拿不到,才叫读不到。把空事件冒充 undefined,会让 baseline 变 -1,
503
+ // 整条收活路径被判报废——这是之前白等两分钟的最后一环。
504
+ const readable = await resolveSession(sessionId);
505
+ if (readable === undefined)
506
+ return undefined;
507
+ let events = [];
508
+ try {
509
+ events = readable.snapshotEvents?.() ?? [];
510
+ }
511
+ catch {
512
+ events = [];
513
+ }
514
+ return {
515
+ total: Array.isArray(events) ? events.length : 0,
516
+ assistant: Array.isArray(events)
517
+ ? events.filter(event => event?.type === 'assistant/message').length
518
+ : 0,
519
+ };
520
+ }
521
+
522
+ /**
523
+ * 拿到成员会话对象,两条通道都试:`sessions` 服务,或 `agents` 注册表里
524
+ * 那个活 agent 的 `session`。
525
+ *
526
+ * 团队成员是**子智能体会话**,`sessions.get(id)` 只返回活的普通会话、查不到
527
+ * 它;`agents` 这条通道才是能稳定读到的那条。
528
+ *
529
+ * @returns 会话对象;两条都拿不到时 undefined。
530
+ */
531
+ async function resolveSession(sessionId) {
532
+ const direct = await sessionOf(sessionId);
533
+ if (direct !== undefined && direct !== null)
534
+ return direct;
535
+ try {
536
+ const agents = ctx.get('agents') ?? ctx.root?.get?.('agents');
537
+ const viaAgent = agents?.get?.(sessionId)?.session;
538
+ if (viaAgent !== undefined && viaAgent !== null && viaAgent !== direct)
539
+ return viaAgent;
540
+ }
541
+ catch {
542
+ // agents 服务不可用。
543
+ }
544
+ return undefined;
545
+ }
546
+
547
+ /** 宿主报告的成员状态;读不到时 undefined。 */
548
+ function agentStatusOf(sessionId) {
549
+ try {
550
+ const agents = ctx.get('agents') ?? ctx.root?.get?.('agents');
551
+ const agent = agents?.get?.(sessionId);
552
+ const status = agent?.status ?? agent?.state;
553
+ return typeof status === 'string' && status !== '' ? status : undefined;
554
+ }
555
+ catch {
556
+ return undefined;
557
+ }
558
+ }
559
+
560
+ /**
561
+ * 把成员会话的模型切成承包商配的那套。
562
+ *
563
+ * 宿主 `selectModel` 会校验 provider/model/思考等级,任何一项不被接受都会抛
564
+ * `session/model-unavailable`;思考等级最容易不匹配(同一个模型在不同
565
+ * provider 上支持的档位不一样),所以它失败时去掉档次再试一次。
566
+ *
567
+ * @returns `{ status, detail }`:`switched` / `follow-lead` / `unsupported` / `failed`。
568
+ */
569
+ async function switchModel(sessionId, member) {
570
+ if (member.provider === '' || member.model === '')
571
+ return { status: 'follow-lead', detail: '' };
572
+ const controller = ctx.get('sessionController');
573
+ const select = controller?.selectModel;
574
+ if (typeof select !== 'function')
575
+ return { status: 'unsupported', detail: '宿主没有 sessionController.selectModel' };
576
+ const attempt = async (withEffort) => await select.call(controller, {
577
+ sessionId,
578
+ provider: member.provider,
579
+ model: member.model,
580
+ ...(withEffort && member.reasoningEffort !== '' ? { reasoningEffort: member.reasoningEffort } : {}),
581
+ });
582
+ try {
583
+ const result = await attempt(true);
584
+ const applied = result?.selected ?? undefined;
585
+ return { status: 'switched', detail: applied === undefined ? '' : `${applied.provider}/${applied.model}` };
586
+ }
587
+ catch (error) {
588
+ const first = errorMessageOf(error);
589
+ if (member.reasoningEffort !== '') {
590
+ try {
591
+ const retry = await attempt(false);
592
+ const applied = retry?.selected ?? undefined;
593
+ return { status: 'switched', detail: `${applied === undefined ? '' : `${applied.provider}/${applied.model}`}(${first},已去掉思考等级重试)` };
594
+ }
595
+ catch (second) {
596
+ const detail = errorMessageOf(second);
597
+ logger?.warn?.(`dsh-agent-foreman: 切换成员模型失败:${detail}`);
598
+ return { status: 'failed', detail };
599
+ }
600
+ }
601
+ logger?.warn?.(`dsh-agent-foreman: 切换成员模型失败:${first}`);
602
+ return { status: 'failed', detail: first };
603
+ }
604
+ }
605
+
606
+ /** 把宿主的 RemoteError 之类的错误压成一行文字。 */
607
+ function errorMessageOf(error) {
608
+ if (error === null || error === undefined)
609
+ return 'unknown';
610
+ const code = error.code === undefined ? '' : `${String(error.code)}: `;
611
+ return `${code}${String(error.message ?? error)}`.slice(0, 200);
612
+ }
613
+
614
+ /**
615
+ * 派一张工单给指定成员并等它交付。
616
+ *
617
+ * 时序:`ensureMember`(spawn 或认领 → **等 phase=active** → 切模型)→ 立共享
618
+ * 任务 → `sendMessage`。第 3 步以前成员不在 active,宿主的 `sendMessage` 会抛
619
+ * `active teammate "x" not found`;所以「等 active」是不能跳的一步。
620
+ *
621
+ * @returns `{ ok: true, text, sessionId, note, actualModel }` 或 `{ ok: false, reason }`。
622
+ */
623
+ /**
624
+ * 自建成员派单:`subagents.startActivation` + `agentOptions`(锁定模型)。
625
+ *
626
+ * 仿 agent-teams 但**带模型可选**:
627
+ * - **模型**:`request.agentOptions` 创建时锁定 provider/model/reasoningEffort;
628
+ * - **回收**:`delivery: 'caller'` 让 result 同步回传,不依赖读会话;
629
+ * - **团队标签页**:通过 `agentTeams.createTask` 立共享任务(宿主面板可见)。
630
+ *
631
+ * @returns `{ ok: true, text, note }` 或 `{ ok: false, reason }`
632
+ */
633
+ async function dispatchViaActivation({ order, parent, prompt, signal, policy }) {
634
+ const subagents = ctx.get('subagents') ?? ctx.root?.get?.('subagents');
635
+ if (subagents === undefined || typeof subagents.startActivation !== 'function')
636
+ return { ok: false, reason: '宿主没有 subagents 服务,无法自建成员' };
637
+ const member = order.member;
638
+ const agentOptions = (member.provider !== '' && member.model !== '')
639
+ ? {
640
+ provider: member.provider,
641
+ model: member.model,
642
+ ...(member.reasoningEffort !== '' ? { reasoningEffort: member.reasoningEffort } : {}),
643
+ }
644
+ : undefined;
645
+ // 共享任务(团队标签页可见)——失败不阻断派单。
646
+ const teams = (ctx.get('agentTeams') ?? ctx.root?.get?.('agentTeams'));
647
+ const task = (teams && typeof teams.createTask === 'function')
648
+ ? await openTask({ teams, parent, order, prompt, name: member.name })
649
+ : undefined;
650
+ try {
651
+ const activation = await subagents.startActivation({
652
+ provider: policy.provider ?? 'spawn',
653
+ label: `${order.id} ${order.title}`.slice(0, 80),
654
+ request: {
655
+ prompt: [{ type: 'text', text: prompt }],
656
+ parent,
657
+ ...(agentOptions === undefined ? {} : { agentOptions }),
658
+ maxDepth: policy.maxDepth ?? 1,
659
+ },
660
+ signal,
661
+ delivery: 'caller',
662
+ });
663
+ order.childId = String(activation.childId);
664
+ const result = await activation.result;
665
+ const text = extractText(result?.output);
666
+ const stopReason = typeof result?.stopReason === 'string' ? result.stopReason : 'completed';
667
+ if (stopReason !== 'completed') {
668
+ await closeTask({ teams, parent, task, done: false });
669
+ return { ok: false, reason: `子智能体${stopReason === 'error' ? '出错' : stopReason}` };
670
+ }
671
+ await closeTask({ teams, parent, task, done: true });
672
+ const routePart = agentOptions === undefined ? '跟随父会话' : `${agentOptions.provider}/${agentOptions.model}`;
673
+ return {
674
+ ok: true,
675
+ text,
676
+ note: `自建成员 · ${routePart}${task === undefined ? '' : ` · 共享任务 ${task.id}`}`,
677
+ };
678
+ }
679
+ catch (error) {
680
+ await closeTask({ teams, parent, task, done: false });
681
+ return { ok: false, reason: explainActivationError(error) };
682
+ }
683
+ }
684
+
685
+ async function dispatch({ order, parent, prompt, signal, policy }) {
686
+ // 自建成员模式:用 startActivation + agentOptions 锁定模型,同步回收产物。
687
+ // 这取代 spawnTeammate(后者无法指定模型,且 selectModel 被 agent-busy 拒绝)。
688
+ const viaActivation = await dispatchViaActivation({ order, parent, prompt, signal, policy });
689
+ if (viaActivation.ok === true || viaActivation.reason !== '宿主没有 subagents 服务,无法自建成员')
690
+ return viaActivation;
691
+ // subagents 不可用时退回旧路径(spawnTeammate + sendMessage)。
692
+ const teams = service();
693
+ let entry;
694
+ try {
695
+ entry = await ensureMember({ member: order.member, signal, parent });
696
+ }
697
+ catch (error) {
698
+ if (error instanceof MemberNotReady) {
699
+ // 成员没起来不该抛给用户:交回引擎退回普通模式。
700
+ logger?.warn?.(`dsh-agent-foreman: 团队成员未就绪:${error.message}`);
701
+ return { ok: false, reason: error.message };
702
+ }
703
+ throw error;
704
+ }
705
+ const { sessionId, name, switchStatus, switchDetail, actualModel } = entry;
706
+ // 先立一块「共享任务」再发消息:宿主的团队面板只认任务板,
707
+ // 光发消息的话那边永远显示「暂无共享任务」。
708
+ const task = await openTask({ teams, parent, order, prompt, name });
709
+ // baseline 必须在发消息**之前**取:消息一投进去,成员会话里就会多出
710
+ // user/message,取晚了会把工单本身当成「还没投递」(判成 no-start)。
711
+ //
712
+ // "取不到"和"真的是 0"必须区分:会话服务这次拿不到时,若冒充 0,后面
713
+ // 读回复会从会话开头扫,命中上一单的旧正文——把旧结果当本单交付,比超时
714
+ // 危险得多。所以先重试几次争取拿到真值,实在拿不到才退 -1 哨兵。
715
+ let baseline = await eventCount(sessionId);
716
+ for (let attempt = 0; attempt < 5 && baseline < 0; attempt += 1) {
717
+ await new Promise(resolve => setTimeout(resolve, WAIT_STEP_MS));
718
+ baseline = await eventCount(sessionId);
719
+ }
720
+ order.baselineNote = `baseline=${baseline}`;
721
+ // baseline 读不到(成员会话不在插件可见范围内)**不再是致命问题**:
722
+ // 产物回收已有第二条通道——承包商按提示词调用 `foreman_report` 自报,
723
+ // 插件把摘要写进 order.reportOutput。所以照样投递、照样等,只是等的
724
+ // 目标从"读会话正文"换成"等自报"。这正是 dsh-agent-teams 的做法:
725
+ // 它从不读成员会话,产物全靠成员自报到插件自己的状态层。
726
+ if (baseline < 0)
727
+ order.baselineNote += '(会话读不到,走自报通道)';
728
+ // 发消息前再确认一次:从 ensureMember 到这里之间成员理论上不会退回
729
+ // provisioning,但宿主驳回时把原因带回去,比抛原始错误好读。
730
+ try {
731
+ await waitActive({ teams, parent, name, signal });
732
+ await teams.sendMessage(parent, {
733
+ target: name,
734
+ content: [{ type: 'text', text: prompt }],
735
+ signal,
736
+ });
737
+ }
738
+ catch (error) {
739
+ await closeTask({ teams, parent, task, done: false });
740
+ if (error instanceof MemberNotReady)
741
+ return { ok: false, reason: error.message };
742
+ throw error;
743
+ }
744
+ // 收活:自报优先,会话正文兜底。
745
+ //
746
+ // 为什么自报优先:成员的子会话宿主可能读不到(sessions.get 查不到子智能体
747
+ // 会话),但 `foreman_report` 是我们自己注册的工具、走自己的存储,不受那个
748
+ // 限制。而且自报是成员**主动**提交的最终结论,比回收会话里最后一条 assistant
749
+ // 正文更可靠(后者可能是中间过程的话)。
750
+ const delivery = await waitForDelivery({
751
+ order, sessionId, baseline, signal, timeoutMs: policy.timeoutSeconds * 1000,
752
+ });
753
+ if (delivery.kind !== 'report' && delivery.kind !== 'session') {
754
+ await closeTask({ teams, parent, task, done: false });
755
+ // 诊断信息一并带回:超时时最需要知道的是「到底投递没有、基线是多少」,
756
+ // 而不是只有一句「成员超时没回」——那没法区分是没派出去还是没收到。
757
+ const diag = `${order.baselineNote ?? ''}${entry?.handshakeNote === undefined ? '' : ` · ${entry.handshakeNote}`}`;
758
+ return { ok: false, reason: `${WAIT_REASON[delivery.kind] ?? delivery.kind}${diag === '' ? '' : `(${diag})`}` };
759
+ }
760
+ const text = delivery.text;
761
+ const viaPart = delivery.kind === 'report' ? '自报' : '会话正文';
762
+ await closeTask({ teams, parent, task, done: true });
763
+ const taskPart = task === undefined ? '' : ` · 共享任务 ${task.id}`;
764
+ const modelPart = modelNoteOf(order.member, actualModel);
765
+ const switchPart = switchStatus === 'switched' && switchDetail === ''
766
+ ? ''
767
+ : `(模型切换:${switchStatus}${switchDetail === '' ? '' : ` · ${switchDetail}`})`;
768
+ const routePart = `${order.member.provider}/${order.member.model}${order.member.reasoningEffort === '' ? '' : `@${order.member.reasoningEffort}`}`;
769
+ return {
770
+ ok: true,
771
+ sessionId,
772
+ text,
773
+ actualModel,
774
+ // 末尾固定追加宿主回显的实际模型:名册说一套、成员跑另一套时用户才看得见。
775
+ note: `${routePart}${switchPart}${taskPart} · 回收:${viaPart}${modelPart === '' ? '' : ` · ${modelPart}`}`,
776
+ };
777
+ }
778
+
779
+ /**
780
+ * 在团队的共享任务板上立一块任务并派给它的人。
781
+ *
782
+ * 宿主语义:`createTask` 建出来的任务要 `reassign` 给成员才会变成
783
+ * `in_progress`(只有 Lead 能 reassign,正好是这里)。任何一步失败都不
784
+ * 影响派单本身,只是团队面板里看不到这块任务。
785
+ *
786
+ * @returns 任务视图(`{ id, revision }`),没建成时 undefined。
787
+ */
788
+ async function openTask({ teams, parent, order, prompt, name }) {
789
+ if (typeof teams.createTask !== 'function')
790
+ return undefined;
791
+ try {
792
+ const created = await teams.createTask(parent, {
793
+ subject: `${order.id} ${order.title}`.slice(0, 200),
794
+ description: prompt.slice(0, 16000),
795
+ });
796
+ const assigned = await teams.updateTask(parent, {
797
+ taskId: created.id,
798
+ expectedRevision: created.revision,
799
+ action: 'reassign',
800
+ owner: name,
801
+ });
802
+ return { id: assigned?.id ?? created.id, revision: assigned?.revision ?? created.revision };
803
+ }
804
+ catch (error) {
805
+ logger?.warn?.(`dsh-agent-foreman: 建共享任务失败:${errorMessageOf(error)}`);
806
+ return undefined;
807
+ }
808
+ }
809
+
810
+ /** 把共享任务收尾:成功→completed,失败→删掉(别在面板上挂一块假进行中)。 */
811
+ async function closeTask({ teams, parent, task, done }) {
812
+ if (task === undefined || typeof teams.updateTask !== 'function')
813
+ return;
814
+ try {
815
+ await teams.updateTask(parent, {
816
+ taskId: task.id,
817
+ expectedRevision: task.revision,
818
+ action: done ? 'complete' : 'delete',
819
+ });
820
+ }
821
+ catch (error) {
822
+ logger?.warn?.(`dsh-agent-foreman: 收尾共享任务 ${task.id} 失败:${errorMessageOf(error)}`);
823
+ }
824
+ }
825
+
826
+ /** 取会话对象:`sessions.get` 可能是同步也可能是异步,两种都认。 */
827
+ async function sessionOf(sessionId) {
828
+ const sessions = ctx.get('sessions') ?? ctx.root?.get?.('sessions');
829
+ const maybe = sessions?.get?.(sessionId);
830
+ const session = maybe !== null && typeof maybe === 'object' && typeof maybe.then === 'function' ? await maybe : maybe;
831
+ return session ?? undefined;
832
+ }
833
+
834
+ /**
835
+ * 成员现在处在哪一步。
836
+ *
837
+ * - `waiting`:我们的工单还没进到成员会话里(它可能还在跑握手那一轮);
838
+ * - `running`:成员正在干活;
839
+ * - `idle`:工单已经收到、人也停了,可以收产物。
840
+ */
841
+ async function turnPhase(sessionId, baseline) {
842
+ const session = await sessionOf(sessionId);
843
+ const events = session?.snapshotEvents?.() ?? [];
844
+ // baseline < 0 是"取不到基线"的哨兵:此时没法从事件判断这一轮,
845
+ // 只能完全依赖宿主状态,绝不能退成 slice(0) 去看会话开头的旧事件。
846
+ const blind = !Number.isInteger(baseline) || baseline < 0;
847
+ const fresh = blind ? [] : events.slice(baseline);
848
+ const hasAssistant = fresh.some(event => event?.type === 'assistant/message');
849
+ const hasUser = fresh.some(event => event?.type === 'user/message');
850
+ const agents = ctx.get('agents') ?? ctx.root?.get?.('agents');
851
+ const agent = agents?.get?.(sessionId);
852
+ const status = agent?.status ?? agent?.state ?? session?.status ?? session?.agent?.status;
853
+ const running = typeof status === 'string' && status !== '' ? status === 'running' : undefined;
854
+
855
+ // 投递判定走两条通道,任一为真即视为已投递:
856
+ // 1. 会话事件里出现 user/message(最直接);
857
+ // 2. 宿主报告这个成员正 running,或已经产出过 assistant 正文。
858
+ // 只信通道 1 太脆:会话对象取不到、或宿主把投递记在别的事件面上时,
859
+ // 会一直判 waiting 然后空等 START_TIMEOUT_MS 才放弃——那正是之前
860
+ // 「团队模式失败(成员没起来)」的真正来源,而成员其实早就干完了。
861
+ const delivered = hasUser || hasAssistant || running === true;
862
+
863
+ if (!delivered)
864
+ return running === false ? 'stalled' : 'waiting';
865
+ if (running === undefined) {
866
+ // 没有状态可看时按事件推断:最后一条是 assistant 就算停了。
867
+ // blind 情况下没有可信事件,无法判断结束——按"还在跑"处理,
868
+ // 由上层超时兜底,而不是凭旧事件误判成 idle 交回旧正文。
869
+ if (blind)
870
+ return 'running';
871
+ for (let index = fresh.length - 1; index >= 0; index -= 1) {
872
+ if (fresh[index]?.type === 'assistant/message')
873
+ return 'idle';
874
+ if (fresh[index]?.type === 'user/message')
875
+ return 'running';
876
+ }
877
+ return 'running';
878
+ }
879
+ // 已产出 assistant 正文、且宿主报告不在 running:这一轮确实结束了,
880
+ // 不必等状态自己翻成 inactive(有些宿主会保留 running 到下一次轮询)。
881
+ return running ? 'running' : 'idle';
882
+ }
883
+
884
+ /**
885
+ * 等这一轮的产物回来:**自报优先,会话正文兜底**。
886
+ *
887
+ * - 自报:承包商按提示词调用 `foreman_report`,插件把摘要写进
888
+ * `order.reportOutput`(同一个 order 对象,所以这里能直接看到)。这条路
889
+ * **不依赖读成员会话**,正是 dsh-agent-teams 的做法——它从不读成员会话,
890
+ * 产物全靠成员自报到插件自己的状态层。
891
+ * - 会话正文:baseline 有效时,读成员会话里这一轮的 assistant 正文。
892
+ *
893
+ * 自报优先的理由:自报是成员**主动**提交的最终结论,比回收会话里最后一条
894
+ * assistant 正文更可靠(后者可能是中间过程的话)。
895
+ *
896
+ * @returns `{ kind: 'report'|'session'|'aborted'|'timeout'|'unobservable', text? }`
897
+ */
898
+ async function waitForDelivery({ order, sessionId, baseline, signal, timeoutMs }) {
899
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
900
+ const startedAt = Date.now();
901
+ const canReadSession = Number.isInteger(baseline) && baseline >= 0;
902
+ // 两条通道都不可用时没必要等:既读不到会话,也没人会上报。
903
+ if (!canReadSession && order === undefined)
904
+ return { kind: 'unobservable' };
905
+ for (;;) {
906
+ // 1. 自报通道:成员主动提交了交付摘要。
907
+ const reported = typeof order?.reportOutput === 'string' ? order.reportOutput.trim() : '';
908
+ if (reported !== '')
909
+ return { kind: 'report', text: reported };
910
+ // 2. 会话通道:baseline 有效且已产出正文。
911
+ if (canReadSession && await hasAssistantText(sessionId, baseline) === true) {
912
+ const text = await readReply(sessionId, baseline);
913
+ if (text !== '')
914
+ return { kind: 'session', text };
915
+ }
916
+ if (signal?.aborted === true)
917
+ return { kind: 'aborted' };
918
+ if (Date.now() - startedAt > timeoutMs)
919
+ return { kind: 'timeout' };
920
+ await sleep(WAIT_STEP_MS);
921
+ }
922
+ }
923
+
924
+ /** 等成员收到工单并跑完。 */
925
+ async function waitForTurn({ sessionId, baseline, signal, timeoutMs }) {
926
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
927
+ const startedAt = Date.now();
928
+ // 读不到会话时,判定完全依赖宿主状态;如果状态也读不到,两个阶段都会
929
+ // 白等到各自的超时。这里先探测一次:两条通道(事件、状态)都不可观测,
930
+ // 就别再耗整单预算了,直接判"这一轮无法观测",让引擎退回普通模式。
931
+ const observable = (await sessionEventSummary(sessionId)) !== undefined
932
+ || agentStatusOf(sessionId) !== undefined;
933
+ if (!observable)
934
+ return 'unobservable';
935
+
936
+ // 第一阶段:等这一轮真的开始(成员收到工单并在跑)。
937
+ // 限时取 START_TIMEOUT_MS 与整单 timeoutMs 的较小者:整单预算若本来
938
+ // 就小于 90 秒,不该在第一阶段就把它耗光。
939
+ const startBudget = Math.min(START_TIMEOUT_MS, timeoutMs);
940
+ for (;;) {
941
+ const phase = await turnPhase(sessionId, baseline);
942
+ if (phase === 'running' || phase === 'idle' || phase === 'stalled')
943
+ break;
944
+ if (signal?.aborted === true)
945
+ return 'aborted';
946
+ if (Date.now() - startedAt > startBudget)
947
+ return 'no-start';
948
+ await sleep(WAIT_STEP_MS);
949
+ }
950
+ // 第二阶段:等这一轮结束。
951
+ //
952
+ // 判定顺序刻意是「先看有没有正文,再看宿主状态」:已经产出了
953
+ // assistant/message 就说明成员把话说完了一轮,可以直接收活。反过来
954
+ // 优先信宿主 `status` 会踩坑——成员干完之后宿主往往仍报 running
955
+ // (状态刷新延迟,或该 agent 又被下一次调度占住),那会一直等到
956
+ // timeout,把一份已经干完的活判成「成员超时没回」。
957
+ for (;;) {
958
+ if (await hasAssistantText(sessionId, baseline) === true)
959
+ return 'done';
960
+ const phase = await turnPhase(sessionId, baseline);
961
+ if (phase === 'idle')
962
+ return 'done';
963
+ if (phase === 'stalled')
964
+ return 'no-start';
965
+ if (signal?.aborted === true)
966
+ return 'aborted';
967
+ if (Date.now() - startedAt > timeoutMs)
968
+ return 'timeout';
969
+ await sleep(WAIT_STEP_MS);
970
+ }
971
+ }
972
+
973
+ /**
974
+ * baseline 之后是否已经有非空的 assistant 正文。
975
+ *
976
+ * 这是「这一轮真的结束了」最可靠的信号:宿主状态可能滞后,但成员把正文
977
+ * 写进会话是不可逆的事实。两条会话来源都查一遍。
978
+ *
979
+ * @returns 有正文返回 true,否则 false。
980
+ */
981
+ async function hasAssistantText(sessionId, baseline) {
982
+ try {
983
+ const text = await readReply(sessionId, baseline);
984
+ return text !== '';
985
+ }
986
+ catch {
987
+ return false;
988
+ }
989
+ }
990
+
991
+ /**
992
+ * 成员会话里自 baseline 之后最后一条 assistant 正文。
993
+ *
994
+ * `baseline = -1` 是"取不到基线"的哨兵:此时无法判断哪条正文属于本轮,
995
+ * 直接返回空串。宁可报"读不到回复",也绝不把上一单的旧正文当成本单交付。
996
+ */
997
+ async function readReply(sessionId, baseline) {
998
+ if (!Number.isInteger(baseline) || baseline < 0)
999
+ return '';
1000
+ // 会话对象有两个来源:sessions 服务,或 agents 注册表里的活 agent。
1001
+ // 前者取不到时用后者兜底——成员干完活后 agent 可能还在注册表里,
1002
+ // 而 sessions.get 已经拿不到了。两条都取不到才放弃(返回空串)。
1003
+ const candidates = [];
1004
+ const session = await sessionOf(sessionId);
1005
+ if (session !== undefined && session !== null)
1006
+ candidates.push(session);
1007
+ try {
1008
+ const agents = ctx.get('agents') ?? ctx.root?.get?.('agents');
1009
+ const live = agents?.get?.(sessionId);
1010
+ const viaAgent = live?.session;
1011
+ if (viaAgent !== undefined && viaAgent !== null && viaAgent !== session)
1012
+ candidates.push(viaAgent);
1013
+ }
1014
+ catch {
1015
+ // agents 服务不可用:只用 sessions 那一条。
1016
+ }
1017
+ for (const candidate of candidates) {
1018
+ const events = candidate.snapshotEvents?.() ?? [];
1019
+ const fresh = events.slice(Math.max(0, baseline));
1020
+ for (let index = fresh.length - 1; index >= 0; index -= 1) {
1021
+ const event = fresh[index];
1022
+ if (event?.type !== 'assistant/message')
1023
+ continue;
1024
+ const text = contentText(event?.data?.message?.content);
1025
+ if (text !== '')
1026
+ return text;
1027
+ }
1028
+ }
1029
+ return '';
1030
+ }
1031
+
1032
+ /**
1033
+ * 成员会话当前的事件条数,用作「这一轮从哪里开始」的基线。
1034
+ *
1035
+ * 返回 **-1** 表示"这个会话这一次读不到":调用方必须据此放弃读取正文,
1036
+ * 而不是把它当成 0。`0` 的含义是"从会话开头算起",若因为服务暂时不可用
1037
+ * 误退成 0,读回复就会命中最早那一轮的正文,把上一单的旧结果当本单交付。
1038
+ * 那是比超时更危险的错误:父智能体会验收一份根本不属于本单的产出。
1039
+ */
1040
+ /**
1041
+ * 成员会话当前的事件条数,用作「这一轮从哪里开始」的基线。
1042
+ *
1043
+ * 刻意复用 `sessionEventSummary` 的读取通道,而不是自己再读一次
1044
+ * `ctx.get('sessions')`:成员是**子智能体会话**,`sessions.get(id)` 只返回活
1045
+ * 的普通会话,查不到它;真正能读到的是 `agents` 注册表里那个活 agent 的
1046
+ * session。之前这里少了 agents 兜底,于是明明能读到会话,`baseline` 却仍是
1047
+ * -1,整条收活路径被判成"不可观测"而报废——线上表现为白等两分钟。
1048
+ *
1049
+ * 返回 **-1** 表示确实读不到:调用方必须据此放弃,不能冒充 0(`0` 的含义是
1050
+ * "从会话开头算起",会命中上一单的旧正文,把旧结果当本单交付)。
1051
+ */
1052
+ async function eventCount(sessionId) {
1053
+ const summary = await sessionEventSummary(sessionId);
1054
+ return summary === undefined ? -1 : summary.total;
1055
+ }
1056
+
1057
+ /** 已建的团队成员(只读快照,给路由里的诊断接口用)。 */
1058
+ function snapshot() {
1059
+ return [...members.entries()].map(([contractorId, entry]) => ({ contractorId, ...entry }));
1060
+ }
1061
+
1062
+ return { ensureMember, dispatch, snapshot, available: () => findService() !== undefined };
1063
+ }
1064
+
1065
+ const WAIT_REASON = {
1066
+ 'no-start': '成员没起来(消息可能没投递)',
1067
+ timeout: '成员超时没回',
1068
+ aborted: '派单被取消',
1069
+ // 成员会话与宿主状态都读不到:这一轮的进展无从判断,再等也只是白烧预算。
1070
+ // 明确区分开,避免和"成员超时没回"混为一谈——后者是等到了超时,前者是根本看不见。
1071
+ unobservable: '读不到成员会话,无法判断这一轮的进展',
1072
+ };
1073
+
1074
+ function teamProvider(ctx) {
1075
+ const wanted = 'spawn';
1076
+ const names = readSubagentProviders(ctx);
1077
+ if (names.length === 0)
1078
+ return wanted;
1079
+ return names.includes(wanted) ? wanted : names[0];
1080
+ }
1081
+
1082
+ export class TeamUnavailable extends Error {
1083
+ constructor(message) {
1084
+ super(message);
1085
+ this.name = 'TeamUnavailable';
1086
+ this.code = 'TEAM_UNAVAILABLE';
1087
+ }
1088
+ }
1089
+
1090
+ /**
1091
+ * 成员没能变成 active(还停在 provisioning、或直接 failed、或被取消)。
1092
+ *
1093
+ * 这不是「宿主坏了」,是这类成员这一单派不出去;`dispatch` 把它转成
1094
+ * `{ ok:false, reason }`,交给引擎退回普通模式,而不是抛给用户。
1095
+ */
1096
+ export class MemberNotReady extends Error {
1097
+ constructor(message) {
1098
+ super(message);
1099
+ this.name = 'MemberNotReady';
1100
+ this.code = 'MEMBER_NOT_READY';
1101
+ }
1102
+ }