dsh-bot-team 0.0.0-stage → 1.3.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/host/engine.js ADDED
@@ -0,0 +1,1698 @@
1
+ /**
2
+ * Turn engine: routes group messages to bot agents, keeps the shared timeline,
3
+ * and streams assistant output to the UI.
4
+ *
5
+ * Per group:
6
+ * - a human message is persisted and broadcast first;
7
+ * - every mentioned member gets a placeholder bot message (stable seq, so the
8
+ * timeline order is the mention order) and a parallel turn (bounded by
9
+ * `maxParallelTurns`; a member never runs two turns at once);
10
+ * - token deltas stream as `message.delta`, step completion corrects with
11
+ * `message.replace`, and `turn/end` finalizes the placeholder;
12
+ * - `stop` cancels running turns and clears the queue; review/discussion
13
+ * orchestration will layer on top of this in M2 (they are inherently serial).
14
+ */
15
+ import {
16
+ buildDiscussionPrompt,
17
+ buildTaskPrompt,
18
+ buildTaskRevisionPrompt,
19
+ buildRevisionPrompt,
20
+ buildReviewPrompt,
21
+ buildSummaryPrompt,
22
+ buildTurnPrompt,
23
+ parseReviewVerdict,
24
+ } from './prompt.js';
25
+ import { newId, now, withTimeout } from './util.js';
26
+ import { DiscussionRunner } from './discussion-runner.js';
27
+ import { isDiscussionActive } from './discussion-flow.js';
28
+ import { LastRequestMetrics } from './message-metrics.js';
29
+ import { currentConversationId, conversationFloor, conversationIdOf, isCurrentMemberSession } from './conversations.js';
30
+
31
+ const executionSeq = (value) => Number.isSafeInteger(value) && value >= 0;
32
+ const executionTurn = (value) => Number.isSafeInteger(value) && value > 0;
33
+ const executionEvent = (type) => typeof type === 'string';
34
+
35
+ export class Engine {
36
+ constructor({ ctx, repo, pool, personas, bus, config, logger, quotaGuard, taskHook, workspaceGuard, assertAdmission, discussionGuard }) {
37
+ this.ctx = ctx;
38
+ this.repo = repo;
39
+ this.pool = pool;
40
+ this.personas = personas;
41
+ this.bus = bus;
42
+ this.config = config;
43
+ this.logger = logger;
44
+ this.quotaGuard = quotaGuard || (() => ({ ok: true }));
45
+ this.taskHook = taskHook || (() => {});
46
+ this.workspaceGuard = workspaceGuard || (() => {});
47
+ this.assertAdmission = assertAdmission || this.workspaceGuard;
48
+ this.pendingWork = new Set();
49
+ this.discussionGuard = discussionGuard || (async () => {});
50
+ this.discussionRunner = new DiscussionRunner(this);
51
+ this.pumpBlocks = new Set();
52
+
53
+ this.queue = [];
54
+ this.running = new Map(); // membershipId -> job
55
+ this.activeBySession = new Map(); // sessionId -> job
56
+ this.waiters = new Map(); // groupId -> [{ resolve }]
57
+ this.discussionWaiters = new Map(); // discussionId -> [{ resolve }]
58
+ this.disposed = false;
59
+
60
+ this.offSession = ctx.on('session/event', (session, event) => this.onSessionEvent(session, event), { global: true });
61
+ this.offClaim = pool.attachClaimHandler?.((payload) => this.onInboxClaim(payload));
62
+ if (ctx.get?.('goals')) this.offGoal = ctx.on('goal/activation-changed', ({ sessionId }) => {
63
+ const job = this.activeBySession.get(sessionId);
64
+ queueMicrotask(() => { this.checkNativeGoal(sessionId, job); this.discussionRunner.nativeSettled(sessionId); });
65
+ }, { global: true });
66
+ }
67
+
68
+ admitConversation(groupId, expectedConversationId) {
69
+ this.assertAdmission(groupId);
70
+ const conversationId = currentConversationId(this.repo, groupId);
71
+ const group = this.repo.getGroup(groupId);
72
+ if (group?.activeConversationId) {
73
+ if (conversationId !== group.activeConversationId) throw Object.assign(new Error('聊天会话归属不可用'), { code: 'conversation-corrupt' });
74
+ conversationFloor(this.repo, groupId);
75
+ }
76
+ if (expectedConversationId !== undefined && expectedConversationId !== conversationId) {
77
+ throw Object.assign(new Error('聊天已开始新会话,请重新选择当前会话'), {
78
+ code: 'stale-conversation', details: { currentConversationId: conversationId },
79
+ });
80
+ }
81
+ return conversationId;
82
+ }
83
+
84
+ isCurrentRecord(record, groupId = record?.groupId) {
85
+ if (!record || record.groupId !== groupId) return false;
86
+ try {
87
+ const current = currentConversationId(this.repo, groupId);
88
+ if (this.repo.getGroup(groupId)?.activeConversationId && !current) return false;
89
+ return !current || conversationIdOf(this.repo, record) === current;
90
+ } catch { return false; }
91
+ }
92
+
93
+ assertCurrentTarget(groupId, record) {
94
+ if (!record || record.groupId !== groupId) {
95
+ throw Object.assign(new Error('目标不存在或不属于本群'), { code: 'not-found' });
96
+ }
97
+ if (!this.isCurrentRecord(record, groupId)) {
98
+ throw Object.assign(new Error('历史会话不能继续执行,请选取内容后在当前会话重新发送'), { code: 'historical-target' });
99
+ }
100
+ return record;
101
+ }
102
+
103
+ isCurrentJob(job) {
104
+ if (!job || !this.isCurrentRecord(job)) return false;
105
+ const member = this.repo.getMember(job.membershipId);
106
+ if (!member || member.groupId !== job.groupId) return false;
107
+ // Legacy adapters without a conversation pointer may expose a different
108
+ // actual Agent id. Once migrated, session ownership is mandatory.
109
+ if (this.repo.getGroup(job.groupId)?.activeConversationId && job.sessionId) {
110
+ return isCurrentMemberSession(this.repo, member, job.sessionId);
111
+ }
112
+ return true;
113
+ }
114
+
115
+ assertJobCurrent(job) {
116
+ if (!this.isCurrentJob(job)) {
117
+ throw Object.assign(new Error('本次执行所属会话已结束'), { code: 'stale-conversation' });
118
+ }
119
+ }
120
+
121
+ assertJobTargets(job) {
122
+ // The pre-migration test adapters have no task/conversation repositories.
123
+ if (!this.repo.getGroup(job.groupId)?.activeConversationId) return;
124
+ const messageIds = [job.triggerMessageId, job.reviewTargetId, job.reviewMessageId,
125
+ job.meta?.retryOf, job.meta?.reviewOf, job.meta?.revisionOf];
126
+ if (job.meta?.taskId) {
127
+ const task = this.assertCurrentTarget(job.groupId, this.repo.getTask?.(job.meta.taskId));
128
+ messageIds.push(task.deliverableMessageId, task.reviewReplyMessageId);
129
+ for (const dependencyId of task.dependencies || task.dependsOn || []) {
130
+ this.assertCurrentTarget(job.groupId, this.repo.getTask?.(dependencyId));
131
+ }
132
+ }
133
+ if (job.discussionId) this.assertCurrentTarget(job.groupId, this.repo.getDiscussion?.(job.discussionId));
134
+ for (const id of messageIds.filter(Boolean)) this.assertCurrentTarget(job.groupId, this.repo.getMessage(id));
135
+ const review = job.reviewMessageId ? this.repo.getMessage(job.reviewMessageId) : null;
136
+ if (review?.meta?.reviewOf) this.assertCurrentTarget(job.groupId, this.repo.getMessage(review.meta.reviewOf));
137
+ }
138
+
139
+ contextFor(job, member, rebuild = false) {
140
+ this.assertJobCurrent(job);
141
+ const floor = conversationFloor(this.repo, job.groupId);
142
+ const from = rebuild ? floor : Math.max(floor, Number.isInteger(member.lastSeenSeq) ? member.lastSeenSeq : 0);
143
+ const context = this.repo.messagesBetween(job.groupId, from, job.triggerSeq)
144
+ .filter(message => message.messageId !== job.placeholderId && this.isCurrentRecord(message, job.groupId));
145
+ const maximum = this.config.maxContextMessages;
146
+ return Number.isInteger(maximum) && maximum > 0 ? context.slice(-maximum) : context;
147
+ }
148
+
149
+ async advanceMemberCursor(job) {
150
+ // These prompts contain an explicit transcript, not the ordinary chat context.
151
+ if (job.discussionId || ['discussion', 'summary', 'discussion_step'].includes(job.kind)) return;
152
+ this.assertJobCurrent(job);
153
+ const member = this.repo.getMember(job.membershipId);
154
+ const lastSeenSeq = Math.max(member.lastSeenSeq || 0, conversationFloor(this.repo, job.groupId), job.triggerSeq || 0);
155
+ const updated = { ...member, lastSeenSeq };
156
+ if (this.repo.putMemberStrict) await this.repo.putMemberStrict(updated, {
157
+ expectedConversationId: job.conversationId, expectedSessionId: member.sessionId, preserveSession: true,
158
+ });
159
+ else {
160
+ // Compatibility with adapters predating strict, copy-on-write members.
161
+ Object.assign(member, updated);
162
+ this.repo.touchMember(member);
163
+ }
164
+ this.assertJobCurrent(job);
165
+ }
166
+
167
+ captureQuote(groupId, replyTo) {
168
+ if (!replyTo) return null;
169
+ const target = this.repo.getMessage(replyTo);
170
+ if (!target || target.groupId !== groupId) {
171
+ throw Object.assign(new Error('回复的原消息不存在或不属于本群'), { code: 'invalid-input' });
172
+ }
173
+ const attachments = (target.attachments || []).map(attachment => {
174
+ const store = this.ctx.get?.('attachments');
175
+ let path;
176
+ try { path = store?.fileHostPath?.(attachment); } catch { /* rejected below */ }
177
+ if (typeof path !== 'string' || !path) {
178
+ throw Object.assign(new Error('引用消息的附件已不可用,请重新选择'), { code: 'invalid-input' });
179
+ }
180
+ return { attachmentId: attachment.attachmentId, name: attachment.name, bytes: attachment.bytes, path };
181
+ });
182
+ const conversationId = conversationIdOf(this.repo, target);
183
+ if (this.repo.getGroup(groupId)?.activeConversationId && !conversationId) {
184
+ throw Object.assign(new Error('引用消息的会话归属不可用'), { code: 'invalid-input' });
185
+ }
186
+ return {
187
+ messageId: target.messageId, conversationId,
188
+ author: { ...target.author }, text: String(target.text || ''), attachments, capturedAt: now(),
189
+ };
190
+ }
191
+
192
+ // ── message helpers ──────────────────────────────────────────────────────
193
+ createMessage(fields) {
194
+ const conversationId = fields.conversationId ?? currentConversationId(this.repo, fields.groupId);
195
+ if (conversationId !== currentConversationId(this.repo, fields.groupId)) {
196
+ throw Object.assign(new Error('本次执行所属会话已结束'), { code: 'stale-conversation' });
197
+ }
198
+ const seq = this.repo.allocateSeq(fields.groupId);
199
+ const message = {
200
+ messageId: newId('msg'),
201
+ groupId: fields.groupId,
202
+ conversationId,
203
+ ...(fields.quote ? { quote: structuredClone(fields.quote) } : {}),
204
+ seq,
205
+ author: fields.author,
206
+ kind: fields.kind ?? 'chat',
207
+ text: typeof fields.text === 'string' ? fields.text : '',
208
+ mentions: Array.isArray(fields.mentions) ? [...fields.mentions] : [],
209
+ attachments: Array.isArray(fields.attachments) ? fields.attachments.map((attachment) => ({ ...attachment })) : [],
210
+ replyTo: fields.replyTo ?? null,
211
+ status: fields.status ?? 'done',
212
+ error: null,
213
+ usage: null,
214
+ generationStats: null,
215
+ provider: fields.provider ?? null,
216
+ model: fields.model ?? null,
217
+ reasoningEffort: fields.reasoningEffort ?? null,
218
+ meta: fields.meta ?? null,
219
+ createdAt: now(),
220
+ updatedAt: now(),
221
+ };
222
+ this.repo.appendMessage(message);
223
+ return message;
224
+ }
225
+
226
+ systemMessage(groupId, text) {
227
+ const message = this.createMessage({
228
+ groupId,
229
+ author: { type: 'system', name: '系统' },
230
+ text,
231
+ status: 'done',
232
+ kind: 'system',
233
+ });
234
+ this.bus.emit(groupId, { type: 'message.added', message });
235
+ return message;
236
+ }
237
+
238
+ /**
239
+ * Timeline card for a bot approval request. The actual allow/reject decision
240
+ * is answered by the DSH approval UI in the bot's own session; this card only
241
+ * points the user there and tracks the outcome.
242
+ */
243
+ approvalMessage(job, data) {
244
+ if (!this.isCurrentJob(job)) return;
245
+ const member = this.repo.getMember(job.membershipId);
246
+ const bot = member ? this.repo.getBot(member.botId) : undefined;
247
+ const name = bot?.name || 'Bot';
248
+ const reason = typeof data.reason === 'string' ? data.reason.trim() : '';
249
+ const text = `${name} 请求权限:工具 ${data.toolName}${reason ? `(${reason})` : ''}。`;
250
+ const message = this.createMessage({
251
+ groupId: job.groupId,
252
+ conversationId: job.conversationId,
253
+ author: { type: 'system', name: '审批' },
254
+ text,
255
+ status: 'done',
256
+ kind: 'approval',
257
+ meta: {
258
+ approvalId: String(data.id || ''),
259
+ membershipId: job.membershipId,
260
+ botId: member?.botId ?? null,
261
+ sessionId: job.sessionId,
262
+ nativeSession: true,
263
+ toolName: String(data.toolName || ''),
264
+ reason,
265
+ approvalState: 'pending',
266
+ },
267
+ });
268
+ job.approvalMessages = job.approvalMessages || new Map();
269
+ job.approvalMessages.set(message.meta.approvalId, message);
270
+ this.bus.emit(job.groupId, { type: 'message.added', message });
271
+ return message;
272
+ }
273
+
274
+ settleApproval(job, approvalId, outcome) {
275
+ const key = String(approvalId || '');
276
+ const message = job.approvalMessages?.get(key);
277
+ if (!message) return;
278
+ message.meta = { ...(message.meta || {}), approvalState: outcome || 'unavailable', decidedAt: now() };
279
+ message.updatedAt = now();
280
+ this.repo.putMessage(message);
281
+ this.bus.emit(job.groupId, { type: 'message.updated', message });
282
+ job.approvalMessages.delete(key);
283
+ }
284
+
285
+ // ── public dispatch ──────────────────────────────────────────────────────
286
+ assertQuota(groupId) {
287
+ this.assertAdmission(groupId);
288
+ const result = this.quotaGuard(groupId);
289
+ if (!result || result.ok) return;
290
+ const error = new Error(result.reason || '本群额度已用完,请稍后再试');
291
+ error.code = 'quota';
292
+ throw error;
293
+ }
294
+
295
+ postHumanMessage({ groupId, text, mentions = [], author, replyTo, attachments, humanInput = true, expectedConversationId }) {
296
+ const conversationId = this.admitConversation(groupId, expectedConversationId);
297
+ const group = this.repo.getGroup(groupId);
298
+ if (!group) throw new Error('group ' + groupId + ' does not exist');
299
+ const command = String(text || '').trim();
300
+ if (command === '/new') throw Object.assign(new Error('请使用新建会话管理操作'), { code: 'command-required' });
301
+ if (/^\/new\s/.test(command)) throw Object.assign(new Error('/new 不接受参数'), { code: 'invalid-command' });
302
+ const quote = this.captureQuote(groupId, replyTo);
303
+ const quoteSize = quote ? quote.text.length + JSON.stringify(quote.author).length + JSON.stringify(quote.attachments).length : 0;
304
+ const inputSize = String(text || '').length + quoteSize + (attachments?.length ? JSON.stringify(attachments).length : 0);
305
+ if (inputSize > (this.config.maxMessageChars || 40000)) {
306
+ throw Object.assign(new Error('消息与引用内容超过输入大小上限,请减少内容后重试'), { code: 'invalid-input' });
307
+ }
308
+ const replyToId = quote?.messageId || null;
309
+ const message = this.createMessage({
310
+ groupId,
311
+ author: author || { type: 'human', name: '用户' },
312
+ text,
313
+ mentions,
314
+ attachments,
315
+ replyTo: replyToId,
316
+ conversationId,
317
+ quote,
318
+ status: 'done',
319
+ });
320
+ this.bus.emit(groupId, { type: 'message.added', message });
321
+
322
+ const targets = [];
323
+ for (const membershipId of mentions) {
324
+ const member = this.repo.getMember(membershipId);
325
+ if (!member || member.groupId !== groupId || member.enabled === false) continue;
326
+ if (!this.repo.getBot(member.botId)) continue;
327
+ if (!targets.some((candidate) => candidate.membershipId === member.membershipId)) targets.push(member);
328
+ }
329
+ if (targets.length > 0) this.assertQuota(groupId);
330
+ for (const member of targets) {
331
+ this.queue.push({
332
+ jobId: newId('job'),
333
+ groupId,
334
+ conversationId,
335
+ membershipId: member.membershipId,
336
+ triggerSeq: message.seq,
337
+ triggerMessageId: message.messageId,
338
+ kind: 'chat',
339
+ humanInput,
340
+ state: 'queued',
341
+ });
342
+ }
343
+ for (const member of targets) this.pump(groupId);
344
+ return { message, accepted: targets.map((member) => member.membershipId) };
345
+ }
346
+
347
+ pump(groupId) {
348
+ if (this.disposed || this.pumpBlocks.has(groupId)) return;
349
+ const runnable = [];
350
+ const busy = new Set(
351
+ [...this.running.values()].filter((job) => job.groupId === groupId).map((job) => job.membershipId),
352
+ );
353
+ let running = this.runningCount(groupId);
354
+ for (const job of this.queue) {
355
+ if (job.groupId !== groupId) continue;
356
+ if (job.stepId) {
357
+ const discussion = this.repo.getDiscussion(job.discussionId);
358
+ if (!discussion || this.discussionRunner.closed.has(job.discussionId) || !['running', 'finalizing'].includes(discussion.status)) continue;
359
+ }
360
+ if (running >= this.config.maxParallelTurns) break;
361
+ if (busy.has(job.membershipId)) continue;
362
+ busy.add(job.membershipId);
363
+ running += 1;
364
+ runnable.push(job);
365
+ }
366
+ for (const job of runnable) {
367
+ const index = this.queue.indexOf(job);
368
+ if (index >= 0) this.queue.splice(index, 1);
369
+ this.startJob(job);
370
+ }
371
+ }
372
+
373
+ runningCount(groupId) {
374
+ let count = 0;
375
+ for (const job of this.running.values()) if (job.groupId === groupId) count += 1;
376
+ return count;
377
+ }
378
+
379
+ queuedCount(groupId) {
380
+ return this.queue.filter((job) => job.groupId === groupId).length;
381
+ }
382
+
383
+ startJob(job, observed = null) {
384
+ try {
385
+ job.conversationId = this.admitConversation(job.groupId, job.conversationId);
386
+ this.assertJobCurrent(job);
387
+ this.assertJobTargets(job);
388
+ if (observed && !isCurrentMemberSession(this.repo, this.repo.getMember(job.membershipId), observed.session.id)) {
389
+ throw Object.assign(new Error('原生会话已不是当前会话'), { code: 'stale-conversation' });
390
+ }
391
+ } catch (error) {
392
+ job.finished = true;
393
+ job.state = 'error';
394
+ job.error = String(error?.message || error);
395
+ job.done = job.stepId ? this.discussionRunner.finished(job).catch(failure => this.discussionRunner.failed(job.discussionId, failure)) : Promise.resolve();
396
+ job.done.then(() => job.resolveDone?.());
397
+ if (!job.stepId && job.discussionId) this.advanceDiscussion(job.discussionId, false);
398
+ this.checkIdle(job.groupId);
399
+ return;
400
+ }
401
+ job.state = 'running';
402
+ job.turn = null;
403
+ job.submitted = false;
404
+ job.turnStarts = new Map();
405
+ job.observedTurn = null;
406
+ job.observedTurnSeq = -1;
407
+ job.execution = {
408
+ version: 1,
409
+ jobId: job.jobId,
410
+ sessionId: null,
411
+ inputMessageId: null,
412
+ turn: null,
413
+ startSeq: null,
414
+ endSeq: null,
415
+ };
416
+ job.steps = [];
417
+ job.streamText = '';
418
+ job.requestMetrics = new LastRequestMetrics();
419
+ job.finished = false;
420
+ job.following = [];
421
+ job.awaitingContinuation = false;
422
+ job.nativeObserved = observed !== null;
423
+ job.done = new Promise((resolve) => {
424
+ job.resolveDone = resolve;
425
+ });
426
+ this.running.set(job.membershipId, job);
427
+
428
+ const member = this.repo.getMember(job.membershipId);
429
+ const bot = member ? this.repo.getBot(member.botId) : undefined;
430
+ if (!member || !bot) {
431
+ this.finishJob(job, 'error', '成员或 Bot 已不存在');
432
+ return;
433
+ }
434
+ const placeholder = observed?.message || this.createMessage({
435
+ groupId: job.groupId,
436
+ conversationId: job.conversationId,
437
+ author: {
438
+ type: 'bot',
439
+ botId: member.botId,
440
+ membershipId: member.membershipId,
441
+ name: bot.name,
442
+ },
443
+ text: '',
444
+ status: 'pending',
445
+ replyTo: job.triggerMessageId,
446
+ kind: job.messageKind ?? (job.kind === 'discussion' || job.kind === 'summary' ? 'discussion' : job.kind === 'review' ? 'review' : 'chat'),
447
+ provider: bot.provider ?? null,
448
+ model: bot.model ?? null,
449
+ reasoningEffort: bot.reasoningEffort ?? null,
450
+ meta: { ...(job.meta || {}), execution: job.execution },
451
+ });
452
+ job.placeholderId = placeholder.messageId;
453
+ if (observed?.message) {
454
+ placeholder.status = 'pending';
455
+ placeholder.error = null;
456
+ this.bus.emit(job.groupId, { type: 'message.updated', message: placeholder });
457
+ } else this.bus.emit(job.groupId, { type: 'message.added', message: placeholder });
458
+ this.bus.emit(job.groupId, {
459
+ type: 'member.status',
460
+ conversationId: job.conversationId,
461
+ membershipId: member.membershipId,
462
+ status: 'running',
463
+ });
464
+
465
+ const context = this.contextFor(job, member);
466
+ job.context = context;
467
+
468
+ if (observed) {
469
+ job.sessionId = observed.session.id;
470
+ job.submitted = true;
471
+ job.nativeInput = true;
472
+ this.activeBySession.set(job.sessionId, job);
473
+ this.recordExecution(job, { sessionId: job.sessionId, submittedSeq: observed.event.seq }, observed.event.seq);
474
+ return;
475
+ }
476
+ if (this.config.turnTimeoutMs > 0) job.timer = setTimeout(() => this.abortJob(job, 'timeout'), this.config.turnTimeoutMs);
477
+ void this.runJob(job, member, bot, context);
478
+ }
479
+
480
+ async runJob(job, member, bot, context) {
481
+ this.pendingWork.add(job);
482
+ try {
483
+ this.admitConversation(job.groupId, job.conversationId);
484
+ this.assertJobTargets(job);
485
+ if (job.stepId) await this.discussionRunner.prepare(job);
486
+ if (job.finished) return;
487
+ const previousSessionId = member.sessionId;
488
+ const previousConversationId = member.sessionConversationId;
489
+ const agent = await this.pool.ensure(member.membershipId);
490
+ if (job.finished) return;
491
+ if (this.disposed || job.state === 'stopped') {
492
+ this.finishJob(job, 'stopped', '已停止');
493
+ return;
494
+ }
495
+ // ensure persists a replacement member. Never write the pre-ensure object
496
+ // back over the new session mapping or its conversation ownership.
497
+ member = this.repo.getMember(job.membershipId);
498
+ job.sessionId = agent.id;
499
+ this.assertJobCurrent(job);
500
+ this.assertJobTargets(job);
501
+ const rebuild = member.sessionId !== previousSessionId || previousConversationId !== member.sessionConversationId;
502
+ context = this.contextFor(job, member, rebuild);
503
+ job.context = context;
504
+ await this.advanceMemberCursor(job);
505
+ if (job.finished) return;
506
+ this.activeBySession.set(agent.id, job);
507
+ await this.persistExecution(job, { sessionId: agent.id });
508
+ if (job.finished) return;
509
+
510
+ try {
511
+ await this.personas.refresh(bot.botId);
512
+ } catch (error) {
513
+ this.logger?.warn?.(`bot-team: refresh persona ${bot.botId} failed: ${String(error)}`);
514
+ }
515
+
516
+ if (job.finished) return;
517
+ this.admitConversation(job.groupId, job.conversationId);
518
+ this.assertJobCurrent(job);
519
+ this.assertJobTargets(job);
520
+ if (job.stepId) this.discussionRunner.assertDispatch(job);
521
+ const prompt = job.promptText ?? this.buildPrompt(job, bot, context);
522
+ job.promptPreview = prompt.slice(0, 2000);
523
+ const inputMessageId = newId('umsg');
524
+ // Session.seq is the next accepted event index, an inclusive search floor.
525
+ const submittedSeq = agent.session?.seq;
526
+ await this.persistExecution(job, {
527
+ inputMessageId,
528
+ ...(executionSeq(submittedSeq) ? { submittedSeq } : {}),
529
+ });
530
+ // A stop/timeout may have occurred while the durable write was pending.
531
+ if (job.finished) return;
532
+
533
+ this.admitConversation(job.groupId, job.conversationId);
534
+ this.assertJobCurrent(job);
535
+ this.assertJobTargets(job);
536
+
537
+ if (job.stepId) this.discussionRunner.assertDispatch(job);
538
+
539
+ // M0 P1: followup puts the EXACT value into the durable inbox — it must be
540
+ // a complete UserMessage (role + id + content + source). Claims may fire
541
+ // synchronously, so arm correlation only after the write has succeeded.
542
+ job.submitted = true;
543
+ const input = {
544
+ role: 'user',
545
+ id: inputMessageId,
546
+ content: [{ type: 'text', text: prompt }],
547
+ source: job.humanInput === true
548
+ ? { kind: 'user' }
549
+ : { kind: 'bot-team', groupId: job.groupId, ...(job.conversationId ? { conversationId: job.conversationId } : {}), jobId: job.jobId },
550
+ };
551
+ const assertActive = () => {
552
+ if (job.finished) throw Object.assign(new Error('本次执行已结束'), { code: 'stopped' });
553
+ this.assertJobCurrent(job); this.assertJobTargets(job);
554
+ if (job.stepId) this.discussionRunner.assertDispatch(job);
555
+ };
556
+ if (this.pool.submit) await this.pool.submit(agent, input, assertActive);
557
+ else { assertActive(); await agent.followup(input); }
558
+ if (job.finished && job.stepId) this.withdrawDiscussionInput(job);
559
+ await job.done;
560
+ } catch (error) {
561
+ this.finishJob(job, 'error', String(error && error.message ? error.message : error));
562
+ } finally {
563
+ if (job.timer) clearTimeout(job.timer);
564
+ this.pendingWork.delete(job);
565
+ if (job.finished && job.stepId) {
566
+ this.withdrawDiscussionInput(job);
567
+ if (this.repo.getDiscussion(job.discussionId)?.status === 'stopping') this.discussionRunner.checkStopping(job.discussionId);
568
+ }
569
+ }
570
+ }
571
+
572
+ emitExecutionUpdated(job, seq, includeExecution = false) {
573
+ this.bus.emit(job.groupId, {
574
+ type: 'execution.updated',
575
+ messageId: job.placeholderId,
576
+ conversationId: job.conversationId,
577
+ sessionId: job.execution.sessionId,
578
+ seq: executionSeq(seq) ? seq : null,
579
+ ...(includeExecution ? { execution: { ...job.execution } } : {}),
580
+ });
581
+ }
582
+
583
+ async persistExecution(job, patch, seq = null) {
584
+ this.assertJobCurrent(job);
585
+ const message = this.repo.messages.get(job.placeholderId);
586
+ if (!message) throw new Error('无法保存执行记录:回复消息不存在');
587
+ job.execution = { ...job.execution, ...patch };
588
+ message.meta = { ...(message.meta || {}), execution: job.execution };
589
+ message.updatedAt = now();
590
+ // Do not use putMessage here: it deliberately swallows storage failures.
591
+ const saved = this.repo.putMessageStrict(message);
592
+ await saved;
593
+ if (job.stepId) await this.discussionRunner.execution(job);
594
+ if (this.isCurrentJob(job)) this.emitExecutionUpdated(job, seq, true);
595
+ }
596
+
597
+ recordExecution(job, patch, seq) {
598
+ void this.persistExecution(job, patch, seq).catch(error => this.executionFailed(job, error));
599
+ }
600
+
601
+ executionFailed(job, error) {
602
+ if (job.finished) return;
603
+ const scoped = this.activeBySession.get(job.sessionId) === job && (!job.stepId || job.turn !== null);
604
+ this.finishJob(job, 'error', `无法保存执行记录:${String(error?.message || error)}`);
605
+ if (scoped && !job.nativeObserved && job.submitted && job.execution.endSeq === null) {
606
+ try {
607
+ this.ctx.agents.get(job.sessionId)?.cancel?.({ kind: 'user' }, { keepInbox: Boolean(job.stepId) });
608
+ } catch (cancelError) {
609
+ this.logger?.warn?.(`bot-team: cancel agent failed: ${String(cancelError)}`);
610
+ }
611
+ }
612
+ }
613
+
614
+ bindExecutionTurn(job, turn) {
615
+ if (!executionTurn(turn) || (job.turn !== null && job.turn !== turn)) return;
616
+ if (job.turn === turn) return;
617
+ job.turn = turn;
618
+ job.observedTurn = turn;
619
+ const startSeq = job.turnStarts.get(turn) ?? null;
620
+ job.turnStarts.clear();
621
+ void this.persistExecution(job, { turn, startSeq }, startSeq)
622
+ .catch((error) => this.executionFailed(job, error));
623
+ }
624
+
625
+ onInboxClaim(payload) {
626
+ const job = this.activeBySession.get(payload?.agent?.id);
627
+ if (!job || job.finished || job.ending || !job.submitted || !this.isCurrentJob(job)) return;
628
+ if (this.ctx.agents.get(payload.agent.id) !== payload.agent) return;
629
+ if (!job.execution.inputMessageId || payload.message?.id !== job.execution.inputMessageId) return;
630
+ this.bindExecutionTurn(job, payload.turn);
631
+ }
632
+
633
+ /** Reattach group observation to already-live native Agents after plugin reload. */
634
+ async recoverNativeSessions() {
635
+ const query = this.ctx.get?.('sessionQuery');
636
+ for (const member of this.repo.members?.values() || []) {
637
+ if (!isCurrentMemberSession(this.repo, member, member.sessionId)) continue;
638
+ const conversationId = currentConversationId(this.repo, member.groupId);
639
+ const agent = member.sessionId ? this.ctx.agents.get?.(member.sessionId) : null;
640
+ if (!agent || this.activeBySession.has(agent.id)) continue;
641
+ try {
642
+ const snapshot = typeof agent.session?.snapshotEvents === 'function'
643
+ ? { events: agent.session.snapshotEvents() }
644
+ : await query?.readSession?.(agent.id);
645
+ if (this.disposed) return;
646
+ if (currentConversationId(this.repo, member.groupId) !== conversationId ||
647
+ !isCurrentMemberSession(this.repo, this.repo.getMember(member.membershipId), agent.id)) continue;
648
+ this.admitConversation(member.groupId, conversationId);
649
+ if (this.ctx.agents.get(agent.id) !== agent || this.pool.memberForSession?.(agent.id)?.membershipId !== member.membershipId || this.running.has(member.membershipId)) continue;
650
+ const events = snapshot?.events;
651
+ if (!Array.isArray(events)) continue;
652
+ const startIndex = events.findLastIndex(event => event.type === 'turn/start');
653
+ if (startIndex < 0) continue;
654
+ const turnEvents = events.slice(startIndex);
655
+ const start = turnEvents[0];
656
+ if (!executionTurn(start.data?.turn) || !executionSeq(start.seq)) continue;
657
+ const ended = turnEvents.some(event => event.type === 'turn/end' && event.data?.turn === start.data.turn);
658
+ if (ended && !this.nativeGoalPending(agent.id)) continue;
659
+ const previous = this.repo.messagesOf(member.groupId).findLast(message =>
660
+ this.isCurrentRecord(message, member.groupId) && message.author?.membershipId === member.membershipId &&
661
+ message.meta?.execution?.sessionId === agent.id &&
662
+ message.meta.execution.turn === start.data.turn && message.meta.execution.startSeq === start.seq);
663
+ const discussion = previous?.meta?.discussionId ? this.repo.getDiscussion?.(previous.meta.discussionId) : null;
664
+ const linked = discussion && isDiscussionActive(discussion) && this.isCurrentRecord(discussion);
665
+ const kind = linked ? (previous.meta.stepId ? 'discussion_step' : previous.meta.summary ? 'summary' : 'discussion')
666
+ : previous?.meta?.taskPhase === 'revision' ? 'task_revision' : previous?.kind || 'native';
667
+ const job = {
668
+ jobId: previous?.meta?.execution?.jobId || newId('job'),
669
+ groupId: member.groupId, conversationId, membershipId: member.membershipId,
670
+ triggerSeq: member.lastSeenSeq || 0, triggerMessageId: previous?.replyTo || null,
671
+ kind, messageKind: previous?.kind, meta: { ...(previous?.meta || {}), nativeSession: true },
672
+ ...(linked ? { discussionId: discussion.discussionId, stepId: previous.meta.stepId, attemptId: previous.meta.attemptId, round: previous.meta.round } : {}),
673
+ previousReplies: linked ? this.repo.messagesOf(member.groupId).filter(message =>
674
+ message.messageId !== previous?.messageId && message.meta?.execution?.jobId === previous?.meta?.execution?.jobId &&
675
+ message.meta?.attemptId === previous?.meta?.attemptId).map(message => message.messageId) : [],
676
+ };
677
+ this.startJob(job, { session: agent.session, event: start, message: previous });
678
+ for (const event of turnEvents) this.onSessionEvent(agent.session, event);
679
+ } catch (error) {
680
+ this.logger?.warn?.('bot-team: native observation recovery failed: ' + String(error));
681
+ }
682
+ }
683
+ }
684
+
685
+ /** Follow native goals without treating their first completed turn as the task end. */
686
+ nativeGoalPending(sessionId) {
687
+ const member = this.pool.memberForSession?.(sessionId);
688
+ if (!member || !isCurrentMemberSession(this.repo, member, sessionId)) return false;
689
+ const agent = this.ctx.agents.get(sessionId);
690
+ const goals = this.ctx.get?.('goals');
691
+ if (!agent || !goals?.get) return false;
692
+ try {
693
+ const goal = goals.get(agent);
694
+ return goal?.phase === 'active' && goal.activation === 'armed';
695
+ } catch { return false; }
696
+ }
697
+
698
+ checkNativeGoal(sessionId, expectedJob = this.activeBySession.get(sessionId)) {
699
+ const job = this.activeBySession.get(sessionId);
700
+ if (job !== expectedJob || this.disposed || !job?.awaitingContinuation || job.finished || !this.isCurrentJob(job)) return;
701
+ if (this.nativeGoalPending(sessionId)) return;
702
+ const agent = this.ctx.agents.get(sessionId);
703
+ const goal = agent ? this.ctx.get?.('goals')?.get(agent) : null;
704
+ if (goal?.phase === 'blocked') this.finishJob(job, 'error', goal.blockedReason?.message || '原生目标已阻塞');
705
+ else if (goal?.phase === 'paused' || (goal?.phase === 'active' && goal.activation !== 'armed')) this.finishJob(job, 'stopped', '原生目标已暂停');
706
+ else this.finishJob(job, 'done');
707
+ }
708
+
709
+ /** A turn started from the native window, scheduler, or a child reply still belongs in the group. */
710
+ adoptNativeTurn(session, event) {
711
+ if (event.type !== 'turn/start' || !executionTurn(event.data?.turn) || !executionSeq(event.seq)) return null;
712
+ const member = this.pool.memberForSession?.(session.id);
713
+ if (!member || !isCurrentMemberSession(this.repo, member, session.id) || this.running.has(member.membershipId)) return null;
714
+ let conversationId;
715
+ try { conversationId = this.admitConversation(member.groupId); } catch { return null; }
716
+ const job = {
717
+ jobId: newId('job'), groupId: member.groupId, conversationId, membershipId: member.membershipId,
718
+ triggerSeq: member.lastSeenSeq || 0, triggerMessageId: null, kind: 'native',
719
+ meta: { nativeSession: true },
720
+ };
721
+ this.startJob(job, { session, event });
722
+ return job.finished ? null : job;
723
+ }
724
+
725
+ startNativeContinuation(job, session, event) {
726
+ if (!this.isCurrentJob(job)) return false;
727
+ try { this.admitConversation(job.groupId, job.conversationId); } catch { return false; }
728
+ const previous = this.repo.getMessage(job.placeholderId);
729
+ if (!previous) throw new Error('Native continuation lost its preceding reply');
730
+ job.previousReplies = [...(job.previousReplies || []), job.placeholderId];
731
+ job.turn = null;
732
+ job.turnStarts = new Map();
733
+ job.observedTurn = null;
734
+ job.observedTurnSeq = -1;
735
+ job.steps = [];
736
+ job.streamText = '';
737
+ job.streamAttemptId = null;
738
+ job.usage = null;
739
+ job.requestMetrics = new LastRequestMetrics();
740
+ job.awaitingContinuation = false;
741
+ job.nativeInput = true;
742
+ job.execution = {
743
+ version: 1, jobId: job.jobId, sessionId: session.id, inputMessageId: null,
744
+ turn: null, startSeq: null, endSeq: null, submittedSeq: event.seq,
745
+ };
746
+ const message = this.createMessage({
747
+ groupId: job.groupId, conversationId: job.conversationId, author: previous.author, status: 'pending',
748
+ kind: previous.kind, provider: previous.provider, model: previous.model,
749
+ reasoningEffort: previous.reasoningEffort, replyTo: previous.messageId,
750
+ meta: { ...(job.meta || {}), nativeSession: true, execution: job.execution },
751
+ });
752
+ job.placeholderId = message.messageId;
753
+ this.bus.emit(job.groupId, { type: 'message.added', message });
754
+ return true;
755
+ }
756
+
757
+ replayFollowing(job) {
758
+ const following = job.following?.splice(0) || [];
759
+ for (const item of following) {
760
+ if (item.stream) this.onAssistantStream(item.stream);
761
+ else this.onSessionEvent(item.session, item.event);
762
+ }
763
+ }
764
+
765
+ onSessionEvent(session, event) {
766
+ if (!session || !event) return;
767
+ if (this.disposed) return;
768
+ const live = this.ctx.agents.get(session.id);
769
+ if (live?.session && live.session !== session) return;
770
+ if (event.type === 'turn/end') this.discussionRunner.nativeSettled(session.id);
771
+ const job = this.activeBySession.get(session.id) || this.adoptNativeTurn(session, event);
772
+ if (!job || job.finished || !this.isCurrentJob(job)) return;
773
+ if (job.ending) { job.following.push({ session, event }); return; }
774
+ const data = event.data || {};
775
+ if (job.awaitingContinuation) {
776
+ if (event.type === 'turn/start') {
777
+ if (!this.startNativeContinuation(job, session, event)) return;
778
+ }
779
+ else {
780
+ if (event.type.startsWith('goal/')) queueMicrotask(() => this.checkNativeGoal(session.id, job));
781
+ if (executionSeq(event.seq)) this.emitExecutionUpdated(job, event.seq);
782
+ return;
783
+ }
784
+ }
785
+ const correlated = Boolean(job.execution.inputMessageId) || job.nativeInput;
786
+ if (correlated && !job.submitted) return;
787
+ if (executionSeq(event.seq) && executionSeq(job.execution.submittedSeq) && event.seq < job.execution.submittedSeq) return;
788
+
789
+ if (event.type === 'turn/start') {
790
+ if (!executionTurn(data.turn)) return;
791
+ // Legacy test/adapter events may omit both inbox identity and accepted seq.
792
+ // A real submitted job must always wait for an exact claim or input ID.
793
+ if (!correlated) {
794
+ if (!executionSeq(event.seq) && job.turn === null) job.turn = job.observedTurn = data.turn;
795
+ return;
796
+ }
797
+ // Adapters without accepted sequence numbers can still correlate the exact
798
+ // user input; leave their unknown boundary null instead of inventing a seq.
799
+ if (!executionSeq(event.seq) && job.observedTurnSeq < 0) job.observedTurn = data.turn;
800
+ if (executionSeq(event.seq)) {
801
+ if (event.seq > job.observedTurnSeq) {
802
+ job.observedTurn = data.turn;
803
+ job.observedTurnSeq = event.seq;
804
+ }
805
+ if (job.turn === null && !job.turnStarts.has(data.turn)) job.turnStarts.set(data.turn, event.seq);
806
+ if (job.turn === data.turn && job.execution.startSeq === null) {
807
+ void this.persistExecution(job, { startSeq: event.seq }, event.seq)
808
+ .catch((error) => this.executionFailed(job, error));
809
+ }
810
+ }
811
+ return;
812
+ }
813
+ if (event.type === 'user/message' && correlated) {
814
+ const inputId = data.id ?? data.message?.id;
815
+ if (job.nativeInput && !job.execution.inputMessageId && typeof inputId === 'string') {
816
+ this.recordExecution(job, { inputMessageId: inputId }, event.seq);
817
+ }
818
+ if (inputId !== job.execution.inputMessageId) return;
819
+ // Accepted user/message contains the UserMessage itself, without a turn.
820
+ // Its exact ID disambiguates the enclosing turn/start candidate.
821
+ this.bindExecutionTurn(job, executionTurn(data.turn) ? data.turn : job.observedTurn);
822
+ }
823
+ if (job.turn === null) return;
824
+ if (executionTurn(data.turn) ? data.turn !== job.turn : job.observedTurn !== job.turn) return;
825
+ if (executionSeq(event.seq) && executionSeq(job.execution.startSeq) && event.seq < job.execution.startSeq) return;
826
+
827
+ if (event.type === 'turn/end' && data.turn === job.turn && correlated && executionSeq(event.seq)) {
828
+ job.ending = true;
829
+ void this.persistExecution(job, { endSeq: event.seq }, event.seq)
830
+ .then(() => this.finishTurn(job, data.reason))
831
+ .catch((error) => this.executionFailed(job, error));
832
+ return;
833
+ }
834
+ if (executionSeq(event.seq) && executionEvent(event.type)) this.emitExecutionUpdated(job, event.seq);
835
+ if (event.type === 'approval/asked') {
836
+ if (this.config.botApprovalPolicy !== 'never') this.approvalMessage(job, event.data || {});
837
+ return;
838
+ }
839
+ if (event.type === 'approval/decided') {
840
+ this.settleApproval(job, event.data && event.data.id, event.data && event.data.outcome);
841
+ return;
842
+ }
843
+ if ((event.type === 'assistant/message' || event.type === 'assistant/attempt') && event.data.turn === job.turn) {
844
+ job.requestMetrics?.settle(event, job.turn);
845
+ }
846
+ if (event.type === 'assistant/message' && event.data.turn === job.turn) {
847
+ const messageText = (event.data.message.content || [])
848
+ .filter((block) => block && block.type === 'text' && typeof block.text === 'string')
849
+ .map((block) => block.text)
850
+ .join('');
851
+ if (messageText) job.steps.push(messageText);
852
+ if (event.data.usage) job.usage = event.data.usage;
853
+ const combined = job.steps.filter(Boolean).join('\n\n');
854
+ if (combined) {
855
+ this.bus.emit(job.groupId, {
856
+ type: 'message.replace',
857
+ conversationId: job.conversationId,
858
+ messageId: job.placeholderId,
859
+ text: combined,
860
+ });
861
+ }
862
+ return;
863
+ }
864
+ if (event.type === 'turn/end' && data.turn === job.turn) this.finishTurn(job, data.reason);
865
+ }
866
+
867
+ finishTurn(job, reason = {}) {
868
+ if (job.finished) return;
869
+ if (!this.isCurrentJob(job)) { this.finishJob(job, 'stopped', '本次执行所属会话已结束'); return; }
870
+ job.ending = false;
871
+ if (reason.kind === 'completed') {
872
+ if (this.nativeGoalPending(job.sessionId) || job.following?.some(item => item.event?.type === 'turn/start')) {
873
+ this.settleMessage(job, 'done');
874
+ job.awaitingContinuation = true;
875
+ this.replayFollowing(job);
876
+ return;
877
+ }
878
+ this.finishJob(job, 'done');
879
+ } else if (reason.kind === 'aborted') {
880
+ this.finishJob(job, 'stopped', reason.reason && reason.reason.kind === 'user' ? '已停止' : '已中断');
881
+ } else if (reason.kind === 'max-tokens') {
882
+ this.finishJob(job, 'error', '达到输出上限');
883
+ } else {
884
+ const detail = reason.error && reason.error.message ? reason.error.message : `turn ended: ${reason.kind}`;
885
+ this.finishJob(job, 'error', detail);
886
+ }
887
+ }
888
+
889
+ onAssistantStream(payload) {
890
+ if (!payload || !payload.agent || !payload.frame) return;
891
+ if (this.ctx.agents.get(payload.agent.id) !== payload.agent) return;
892
+ const job = this.activeBySession.get(payload.agent.id);
893
+ if (!job || job.finished || !this.isCurrentJob(job)) return;
894
+ if (job.ending) { job.following.push({ stream: payload }); return; }
895
+ if (job.awaitingContinuation || job.turn === null || job.observedTurn !== job.turn) return;
896
+ const frame = payload.frame;
897
+ if (executionTurn(frame.turn) && frame.turn !== job.turn) return;
898
+ if (frame.type === 'start') {
899
+ job.requestMetrics?.start(frame, job.turn);
900
+ job.streamAttemptId = frame.attemptId;
901
+ job.streamText = '';
902
+ return;
903
+ }
904
+ if (frame.attemptId && frame.attemptId !== job.streamAttemptId) return;
905
+ if (frame.type === 'end') {
906
+ job.requestMetrics?.end(frame);
907
+ return;
908
+ }
909
+ if (
910
+ frame.type === 'chunk' &&
911
+ frame.chunk &&
912
+ frame.chunk.type === 'text-delta' &&
913
+ typeof frame.chunk.text === 'string' &&
914
+ frame.chunk.text.length > 0
915
+ ) {
916
+ job.streamText += frame.chunk.text;
917
+ this.bus.emit(job.groupId, {
918
+ type: 'message.delta',
919
+ conversationId: job.conversationId,
920
+ messageId: job.placeholderId,
921
+ chunk: frame.chunk.text,
922
+ });
923
+ }
924
+ }
925
+
926
+ withdrawDiscussionInput(job) {
927
+ if (!job?.stepId || !job.sessionId || !job.execution?.inputMessageId) return 0;
928
+ if (this.activeBySession.get(job.sessionId) !== job && (!this.repo || !this.isCurrentRecord(job))) return 0;
929
+ const inbox = this.ctx.agents.get(job.sessionId)?.inbox;
930
+ if (typeof inbox?.splice !== 'function') return 0;
931
+ let removed = 0;
932
+ try {
933
+ for (const [lane, key] of [['next-turn', 'nextTurn'], ['next-step', 'nextStep']]) {
934
+ if (!Array.isArray(inbox[key])) continue;
935
+ for (let index = inbox[key].length - 1; index >= 0; index -= 1) {
936
+ const input = inbox[key][index];
937
+ if (input?.id !== job.execution.inputMessageId) continue;
938
+ // Persisted input identity remains authoritative if native admission
939
+ // normalizes the source. Preserve an explicitly different job source.
940
+ if (input.source?.kind === 'bot-team' && input.source.jobId && input.source.jobId !== job.jobId) continue;
941
+ inbox.splice(lane, index, 1, []); removed += 1;
942
+ }
943
+ }
944
+ } catch (error) { this.logger?.warn?.('bot-team: exact discussion input withdrawal failed: ' + String(error)); }
945
+ return removed;
946
+ }
947
+
948
+ abortJob(job, cause) {
949
+ if (!job || job.finished) return;
950
+ job.state = 'stopped';
951
+ this.withdrawDiscussionInput(job);
952
+ try {
953
+ job.abortController?.abort();
954
+ } catch {
955
+ /* noop */
956
+ }
957
+ const agent = job.sessionId ? this.ctx.agents.get(job.sessionId) : undefined;
958
+ const owned = agent && this.pool.owns?.(agent);
959
+ const scoped = this.activeBySession.get(job.sessionId) === job && (!job.stepId || job.turn !== null);
960
+ if (agent && scoped && (cause !== 'dispose' || owned) && typeof agent.cancel === 'function') {
961
+ try {
962
+ // A shared native session may contain unrelated queued inputs.
963
+ agent.cancel({ kind: 'user' }, { keepInbox: Boolean(job.stepId) });
964
+ } catch (error) {
965
+ this.logger?.warn?.(`bot-team: cancel agent failed: ${String(error)}`);
966
+ }
967
+ }
968
+ const reason = cause === 'timeout' ? '本轮超时,已停止' : cause === 'dispose' ? (owned ? 'Bot Team 已卸载,后台执行已停止' : 'Bot Team 已断开观察;原生会话仍由 DSH 管理') : '已停止';
969
+ this.finishJob(job, 'stopped', reason);
970
+ }
971
+
972
+ settleMessage(job, status, error) {
973
+ const text = ((job.steps || []).filter(Boolean).join('\n\n') || job.streamText || '').trim();
974
+ const message = job.placeholderId ? this.repo.messages.get(job.placeholderId) : undefined;
975
+ if (message) {
976
+ message.text = text;
977
+ message.status = status;
978
+ message.error = error ?? null;
979
+ message.usage = job.usage ?? null;
980
+ message.generationStats = job.requestMetrics?.finish(status) ?? null;
981
+ message.kind = job.messageKind ?? message.kind ?? 'chat';
982
+ const meta = { ...(message.meta || {}), ...(job.meta || {}), execution: job.execution,
983
+ ...(job.stepId ? { logicalJobComplete: job.finished === true } : {}) };
984
+ if (job.kind === 'review' && status === 'done') {
985
+ const verdict = parseReviewVerdict(text);
986
+ if (verdict) meta.verdict = verdict;
987
+ }
988
+ if (Object.keys(meta).length > 0) message.meta = meta;
989
+ message.updatedAt = now();
990
+ if (job.stepId) {
991
+ const saved = Promise.resolve(this.repo.putMessageStrict(message)).catch(saveError => {
992
+ job.discussionPersistenceError = String(saveError?.message || saveError);
993
+ });
994
+ job.discussionMessageWrites = Promise.all([job.discussionMessageWrites, saved]);
995
+ } else this.repo.putMessage(message);
996
+ this.bus.emit(job.groupId, { type: 'message.updated', message });
997
+ // The accepted end event may have invalidated before this final status.
998
+ // A null seq also refreshes stopped jobs and bypasses event deduplication.
999
+ this.emitExecutionUpdated(job, null, true);
1000
+ }
1001
+ return message;
1002
+ }
1003
+
1004
+ finishJob(job, status, error) {
1005
+ if (!job || job.finished) return;
1006
+ job.finished = true;
1007
+ job.state = status;
1008
+ job.error = error || null;
1009
+ if (job.timer) clearTimeout(job.timer);
1010
+ const current = this.isCurrentJob(job);
1011
+ if (job.sessionId && this.activeBySession.get(job.sessionId) === job) this.activeBySession.delete(job.sessionId);
1012
+ if (this.running.get(job.membershipId) === job) this.running.delete(job.membershipId);
1013
+
1014
+ const message = this.settleMessage(job, status, error);
1015
+ if (job.approvalMessages && job.approvalMessages.size > 0) {
1016
+ for (const approvalId of [...job.approvalMessages.keys()]) {
1017
+ this.settleApproval(job, approvalId, 'cancelled');
1018
+ }
1019
+ }
1020
+ if (current) this.bus.emit(job.groupId, {
1021
+ type: 'member.status',
1022
+ conversationId: job.conversationId,
1023
+ membershipId: job.membershipId,
1024
+ status: status === 'done' ? 'idle' : status === 'stopped' ? 'idle' : 'error',
1025
+ ...(status === 'error' ? { error: error ?? '未知错误' } : {}),
1026
+ });
1027
+ try {
1028
+ const hookResult = current ? this.taskHook(job, message, status) : undefined;
1029
+ if (hookResult && typeof hookResult.then === 'function') {
1030
+ const pendingHook = { groupId: job.groupId, conversationId: job.conversationId };
1031
+ this.pendingWork.add(pendingHook);
1032
+ hookResult.catch((hookError) => {
1033
+ this.logger?.warn?.('bot-team: task hook failed: ' + String(hookError));
1034
+ }).finally(() => this.pendingWork.delete(pendingHook));
1035
+ }
1036
+ } catch (hookError) {
1037
+ this.logger?.warn?.('bot-team: task hook failed: ' + String(hookError));
1038
+ }
1039
+ if (job.stepId) {
1040
+ const pending = { groupId: job.groupId, conversationId: job.conversationId };
1041
+ this.pendingWork.add(pending);
1042
+ void this.discussionRunner.finished(job).catch(failure => this.discussionRunner.failed(job.discussionId, failure))
1043
+ .finally(() => {
1044
+ this.pendingWork.delete(pending); job.resolveDone?.();
1045
+ this.pump(job.groupId); this.checkIdle(job.groupId);
1046
+ });
1047
+ } else {
1048
+ job.resolveDone?.();
1049
+ this.pump(job.groupId);
1050
+ if (current) this.afterJobFinished(job);
1051
+ this.checkIdle(job.groupId);
1052
+ }
1053
+ }
1054
+
1055
+ /** Discussion bookkeeping + review-side effects after one job settles. */
1056
+ afterJobFinished(job) {
1057
+ if (!job || !this.isCurrentJob(job)) return;
1058
+ if (job.kind === 'discussion' && job.discussionId) {
1059
+ this.advanceDiscussion(job.discussionId, job.state === 'done');
1060
+ return;
1061
+ }
1062
+ if (job.kind === 'summary' && job.discussionId) {
1063
+ const discussion = this.repo.getDiscussion(job.discussionId);
1064
+ if (discussion && discussion.status === 'running' && this.isCurrentRecord(discussion)) {
1065
+ discussion.status = job.state === 'done' ? 'done' : 'stopped';
1066
+ discussion.updatedAt = now();
1067
+ this.repo.putDiscussion(discussion);
1068
+ this.bus.emit(discussion.groupId, { type: 'discussion.updated', discussion });
1069
+ this.systemMessage(
1070
+ discussion.groupId,
1071
+ job.state === 'done'
1072
+ ? `讨论「${discussion.topic}」结束。`
1073
+ : `讨论「${discussion.topic}」总结失败/中断,已结束。`,
1074
+ );
1075
+ this.settleDiscussionWaiters(job.discussionId);
1076
+ }
1077
+ }
1078
+ }
1079
+
1080
+ advanceDiscussion(discussionId, ok) {
1081
+ const discussion = this.repo.getDiscussion(discussionId);
1082
+ if (!discussion || discussion.status !== 'running' || !this.isCurrentRecord(discussion)) return;
1083
+ if (!ok) {
1084
+ discussion.status = 'stopped';
1085
+ discussion.stopReason = 'member-error';
1086
+ discussion.updatedAt = now();
1087
+ this.repo.putDiscussion(discussion);
1088
+ this.bus.emit(discussion.groupId, { type: 'discussion.updated', discussion });
1089
+ this.systemMessage(discussion.groupId, `讨论「${discussion.topic}」因成员执行失败而停止。`);
1090
+ this.settleDiscussionWaiters(discussionId);
1091
+ return;
1092
+ }
1093
+ discussion.currentIndex = (discussion.currentIndex ?? 0) + 1;
1094
+ if (discussion.currentIndex >= (discussion.memberIds?.length ?? 0)) {
1095
+ discussion.currentRound = (discussion.currentRound ?? 1) + 1;
1096
+ discussion.currentIndex = 0;
1097
+ }
1098
+ discussion.updatedAt = now();
1099
+ this.repo.putDiscussion(discussion);
1100
+ this.bus.emit(discussion.groupId, { type: 'discussion.updated', discussion });
1101
+ // Serial scheduling: the next speaker starts only after this job settles.
1102
+ setTimeout(() => this.scheduleDiscussion(discussionId), 0);
1103
+ }
1104
+
1105
+ /** Enqueue one task delivery turn for the task's assignee. */
1106
+ enqueueTaskTurn({ taskId, phase, expectedConversationId }) {
1107
+ const task = this.repo.getTask(taskId);
1108
+ if (!task) throw Object.assign(new Error('任务不存在'), { code: 'not-found' });
1109
+ const group = this.repo.getGroup(task.groupId);
1110
+ if (!group) throw Object.assign(new Error('任务所在群不存在'), { code: 'not-found' });
1111
+ const conversationId = this.admitConversation(task.groupId, expectedConversationId);
1112
+ this.assertCurrentTarget(task.groupId, task);
1113
+ const triggerSeq = this.repo.lastMessageOf(task.groupId)?.seq ?? 0;
1114
+ this.enqueueJob({
1115
+ groupId: task.groupId,
1116
+ conversationId,
1117
+ membershipId: task.assigneeMembershipId,
1118
+ kind: phase === 'revision' ? 'task_revision' : 'task',
1119
+ triggerSeq,
1120
+ triggerMessageId: null,
1121
+ messageKind: 'task',
1122
+ meta: { taskId: task.taskId, taskPhase: phase || 'dispatch' },
1123
+ });
1124
+ }
1125
+
1126
+ /** Queue shape for one group: running turns, waiting turns and their task ids. */
1127
+ queueSnapshot(groupId) {
1128
+ const runningJobs = [...this.running.values()].filter((job) => job.groupId === groupId && !job.finished);
1129
+ const queuedJobs = this.queue.filter((job) => job.groupId === groupId);
1130
+ return {
1131
+ running: runningJobs.length,
1132
+ queued: queuedJobs.length,
1133
+ runningTaskIds: runningJobs.map((job) => job.meta && job.meta.taskId).filter(Boolean),
1134
+ queuedTaskIds: queuedJobs.map((job) => job.meta && job.meta.taskId).filter(Boolean),
1135
+ parallelLimit: this.config.maxParallelTurns,
1136
+ };
1137
+ }
1138
+
1139
+ // ── M2: review / revision ───────────────────────────────────────────────
1140
+ enqueueJob(spec) {
1141
+ const conversationId = this.admitConversation(spec.groupId, spec.conversationId ?? spec.expectedConversationId);
1142
+ this.assertJobTargets(spec);
1143
+ const job = {
1144
+ jobId: newId('job'),
1145
+ state: 'queued',
1146
+ ...spec,
1147
+ conversationId,
1148
+ };
1149
+ this.queue.push(job);
1150
+ this.pump(job.groupId);
1151
+ return job;
1152
+ }
1153
+
1154
+ authorNameOf(message) {
1155
+ if (!message) return '未知';
1156
+ if (message.author?.type === 'human') return '用户';
1157
+ if (message.author?.type === 'system') return '系统';
1158
+ const bot = message.author?.botId ? this.repo.getBot(message.author.botId) : undefined;
1159
+ return bot?.name ?? message.author?.name ?? 'Bot';
1160
+ }
1161
+
1162
+ requestReview({ groupId, targetMessageId, reviewerMembershipId, instructions, taskId, expectedConversationId }) {
1163
+ const conversationId = this.admitConversation(groupId, expectedConversationId);
1164
+ const target = this.repo.messages.get(targetMessageId);
1165
+ if (!target || target.groupId !== groupId) throw Object.assign(new Error('待评审消息不存在'), { code: 'not-found' });
1166
+ this.assertCurrentTarget(groupId, target);
1167
+ if (taskId) this.assertCurrentTarget(groupId, this.repo.getTask?.(taskId));
1168
+ const member = this.repo.getMember(reviewerMembershipId);
1169
+ if (!member || member.groupId !== groupId) throw Object.assign(new Error('评审者不在群里'), { code: 'invalid-input' });
1170
+ const reviewer = this.repo.getBot(member.botId);
1171
+ if (!reviewer) throw Object.assign(new Error('评审者 Bot 不存在'), { code: 'not-found' });
1172
+ this.assertQuota(groupId);
1173
+ const requestMessage = this.createMessage({
1174
+ groupId,
1175
+ author: { type: 'system', name: '系统' },
1176
+ text: `请 @${reviewer.name} 评审 #${target.seq}(${this.authorNameOf(target)})的消息`,
1177
+ kind: 'review',
1178
+ status: 'done',
1179
+ mentions: [reviewerMembershipId],
1180
+ replyTo: targetMessageId,
1181
+ meta: { reviewOf: targetMessageId, reviewerMembershipId, instructions: instructions || '', ...(taskId ? { taskId } : {}) },
1182
+ });
1183
+ this.bus.emit(groupId, { type: 'message.added', message: requestMessage });
1184
+ this.enqueueJob({
1185
+ groupId,
1186
+ conversationId,
1187
+ membershipId: reviewerMembershipId,
1188
+ kind: 'review',
1189
+ triggerSeq: requestMessage.seq,
1190
+ triggerMessageId: requestMessage.messageId,
1191
+ reviewTargetId: targetMessageId,
1192
+ instructions: instructions || '',
1193
+ messageKind: 'review',
1194
+ meta: { reviewOf: targetMessageId, reviewerMembershipId, instructions: instructions || '', ...(taskId ? { taskId } : {}) },
1195
+ });
1196
+ return { requestMessage, reviewer: reviewer.name };
1197
+ }
1198
+
1199
+ requestRevision({ groupId, reviewMessageId, expectedConversationId }) {
1200
+ const conversationId = this.admitConversation(groupId, expectedConversationId);
1201
+ const review = this.repo.messages.get(reviewMessageId);
1202
+ if (!review || review.groupId !== groupId) throw Object.assign(new Error('评审消息不存在'), { code: 'not-found' });
1203
+ this.assertCurrentTarget(groupId, review);
1204
+ const targetId = review.meta && review.meta.reviewOf;
1205
+ const target = targetId ? this.repo.messages.get(targetId) : undefined;
1206
+ if (!target) throw Object.assign(new Error('找不到被评审的原消息'), { code: 'not-found' });
1207
+ this.assertCurrentTarget(groupId, target);
1208
+ const membershipId = target.author && target.author.membershipId;
1209
+ if (!membershipId || target.author.type !== 'bot') {
1210
+ throw Object.assign(new Error('只有 Bot 的消息可以要求作者修改'), { code: 'invalid-input' });
1211
+ }
1212
+ const member = this.repo.getMember(membershipId);
1213
+ const bot = member ? this.repo.getBot(member.botId) : undefined;
1214
+ if (!member || !bot) throw Object.assign(new Error('原消息作者已不在群里'), { code: 'not-found' });
1215
+ this.assertQuota(groupId);
1216
+ const reviewerName = this.authorNameOf(review);
1217
+ const requestMessage = this.createMessage({
1218
+ groupId,
1219
+ author: { type: 'system', name: '系统' },
1220
+ text: `请 @${bot.name} 根据 #${review.seq} 的评审意见修改 #${target.seq}`,
1221
+ kind: 'revision',
1222
+ status: 'done',
1223
+ mentions: [membershipId],
1224
+ replyTo: reviewMessageId,
1225
+ meta: { reviewOf: reviewMessageId, revisionOf: target.messageId, reviewerName },
1226
+ });
1227
+ this.bus.emit(groupId, { type: 'message.added', message: requestMessage });
1228
+ this.enqueueJob({
1229
+ groupId,
1230
+ conversationId,
1231
+ membershipId,
1232
+ kind: 'revision',
1233
+ triggerSeq: requestMessage.seq,
1234
+ triggerMessageId: requestMessage.messageId,
1235
+ reviewMessageId,
1236
+ messageKind: 'revision',
1237
+ meta: { reviewOf: reviewMessageId, revisionOf: target.messageId, reviewerName },
1238
+ });
1239
+ return { requestMessage, author: bot.name };
1240
+ }
1241
+
1242
+ retryMessage(messageId, expectedConversationId) {
1243
+ const message = this.repo.messages.get(messageId);
1244
+ if (!message) throw Object.assign(new Error('消息不存在'), { code: 'not-found' });
1245
+ const conversationId = this.admitConversation(message.groupId, expectedConversationId);
1246
+ this.assertCurrentTarget(message.groupId, message);
1247
+ if (message.author?.type !== 'bot') throw Object.assign(new Error('只能重试 Bot 的回复'), { code: 'invalid-input' });
1248
+ const member = this.repo.getMember(message.author.membershipId);
1249
+ if (!member) throw Object.assign(new Error('该 Bot 已不在群里'), { code: 'not-found' });
1250
+ this.assertQuota(message.groupId);
1251
+ const meta = message.meta || {};
1252
+ if (message.kind === 'discussion') {
1253
+ throw Object.assign(new Error('讨论消息不支持单条重试,请重新发起讨论'), { code: 'invalid-input' });
1254
+ }
1255
+ if (message.kind === 'review' && meta.reviewOf) {
1256
+ return this.enqueueJob({
1257
+ groupId: message.groupId,
1258
+ conversationId,
1259
+ membershipId: member.membershipId,
1260
+ kind: 'review',
1261
+ triggerSeq: message.seq,
1262
+ triggerMessageId: message.replyTo || message.messageId,
1263
+ reviewTargetId: meta.reviewOf,
1264
+ instructions: meta.instructions || '',
1265
+ messageKind: 'review',
1266
+ meta: { ...meta, retryOf: message.messageId },
1267
+ });
1268
+ }
1269
+ if (message.kind === 'revision' && meta.reviewOf) {
1270
+ return this.enqueueJob({
1271
+ groupId: message.groupId,
1272
+ conversationId,
1273
+ membershipId: member.membershipId,
1274
+ kind: 'revision',
1275
+ triggerSeq: message.seq,
1276
+ triggerMessageId: message.replyTo || message.messageId,
1277
+ reviewMessageId: meta.reviewOf,
1278
+ messageKind: 'revision',
1279
+ meta: { ...meta, retryOf: message.messageId },
1280
+ });
1281
+ }
1282
+ return this.enqueueJob({
1283
+ groupId: message.groupId,
1284
+ conversationId,
1285
+ membershipId: member.membershipId,
1286
+ kind: 'chat',
1287
+ triggerSeq: message.seq,
1288
+ triggerMessageId: message.replyTo || message.messageId,
1289
+ messageKind: 'chat',
1290
+ meta: { ...meta, retryOf: message.messageId },
1291
+ });
1292
+ }
1293
+
1294
+ // ── M2: multi-round discussion ──────────────────────────────────────────
1295
+ async startDiscussion(input) {
1296
+ return this.discussionRunner.start(input);
1297
+ }
1298
+
1299
+ async controlDiscussion(input) {
1300
+ const discussion = this.repo.getDiscussion(input.discussionId);
1301
+ if (!discussion) throw Object.assign(new Error('讨论不存在'), { code: 'not-found' });
1302
+ if (discussion.protocolVersion === 2) return this.discussionRunner.control(input);
1303
+ this.admitConversation(discussion.groupId, input.expectedConversationId);
1304
+ this.assertCurrentTarget(discussion.groupId, discussion);
1305
+ if (input.operation !== 'stop') throw Object.assign(new Error('旧版讨论只支持停止,请新建讨论以使用步骤控制'), { code: 'invalid-input' });
1306
+ await this.stopDiscussion(input.discussionId);
1307
+ return this.repo.getDiscussion(input.discussionId);
1308
+ }
1309
+
1310
+ scheduleDiscussion(discussionId) {
1311
+ if (this.disposed) return;
1312
+ const discussion = this.repo.getDiscussion(discussionId);
1313
+ if (discussion?.protocolVersion === 2) {
1314
+ if (!this.isCurrentRecord(discussion) || !['running', 'finalizing'].includes(discussion.status)) return;
1315
+ return this.discussionRunner.schedule(discussionId);
1316
+ }
1317
+ if (!discussion || discussion.status !== 'running' || !this.isCurrentRecord(discussion)) return;
1318
+ try {
1319
+ this.assertQuota(discussion.groupId);
1320
+ } catch (error) {
1321
+ this.stopDiscussionInternal(discussion, 'quota', String(error && error.message ? error.message : error));
1322
+ return;
1323
+ }
1324
+ if ((discussion.currentRound ?? 1) > discussion.rounds) {
1325
+ if (discussion.summarize && !discussion.summaryStarted) {
1326
+ discussion.summaryStarted = true;
1327
+ discussion.currentSpeaker = discussion.memberIds[0];
1328
+ this.repo.putDiscussion(discussion);
1329
+ this.bus.emit(discussion.groupId, { type: 'discussion.updated', discussion });
1330
+ this.enqueueJob({
1331
+ groupId: discussion.groupId,
1332
+ conversationId: conversationIdOf(this.repo, discussion),
1333
+ membershipId: discussion.memberIds[0],
1334
+ kind: 'summary',
1335
+ discussionId,
1336
+ round: discussion.rounds,
1337
+ triggerSeq: this.repo.lastMessageOf(discussion.groupId)?.seq ?? discussion.startSeq,
1338
+ triggerMessageId: discussion.startMessageId,
1339
+ messageKind: 'discussion',
1340
+ meta: { discussionId, summary: true },
1341
+ });
1342
+ return;
1343
+ }
1344
+ discussion.status = 'done';
1345
+ discussion.currentSpeaker = null;
1346
+ discussion.updatedAt = now();
1347
+ this.repo.putDiscussion(discussion);
1348
+ this.bus.emit(discussion.groupId, { type: 'discussion.updated', discussion });
1349
+ this.systemMessage(discussion.groupId, `讨论「${discussion.topic}」结束。`);
1350
+ this.settleDiscussionWaiters(discussionId);
1351
+ return;
1352
+ }
1353
+ const membershipId = discussion.memberIds[discussion.currentIndex];
1354
+ const member = membershipId ? this.repo.getMember(membershipId) : undefined;
1355
+ if (!member || member.enabled === false) {
1356
+ discussion.currentIndex = (discussion.currentIndex ?? 0) + 1;
1357
+ if (discussion.currentIndex >= discussion.memberIds.length) {
1358
+ discussion.currentRound = (discussion.currentRound ?? 1) + 1;
1359
+ discussion.currentIndex = 0;
1360
+ }
1361
+ this.repo.putDiscussion(discussion);
1362
+ this.bus.emit(discussion.groupId, { type: 'discussion.updated', discussion });
1363
+ setTimeout(() => this.scheduleDiscussion(discussionId), 0);
1364
+ return;
1365
+ }
1366
+ discussion.currentSpeaker = membershipId;
1367
+ this.repo.putDiscussion(discussion);
1368
+ this.bus.emit(discussion.groupId, { type: 'discussion.updated', discussion });
1369
+ this.enqueueJob({
1370
+ groupId: discussion.groupId,
1371
+ conversationId: conversationIdOf(this.repo, discussion),
1372
+ membershipId,
1373
+ kind: 'discussion',
1374
+ discussionId,
1375
+ round: discussion.currentRound,
1376
+ triggerSeq: this.repo.lastMessageOf(discussion.groupId)?.seq ?? discussion.startSeq,
1377
+ triggerMessageId: discussion.startMessageId,
1378
+ messageKind: 'discussion',
1379
+ meta: { discussionId, round: discussion.currentRound, speaker: membershipId },
1380
+ });
1381
+ this.checkIdle(discussion.groupId);
1382
+ }
1383
+
1384
+ stopDiscussionInternal(discussion, reason, message) {
1385
+ if (!discussion || discussion.status !== 'running' || !this.isCurrentRecord(discussion)) return;
1386
+ discussion.status = 'stopped';
1387
+ discussion.stopReason = reason;
1388
+ discussion.currentSpeaker = null;
1389
+ this.repo.putDiscussion(discussion);
1390
+ this.bus.emit(discussion.groupId, { type: 'discussion.updated', discussion });
1391
+ this.systemMessage(discussion.groupId, '讨论「' + discussion.topic + '」已停止:' + message);
1392
+ this.queue = this.queue.filter((queued) => queued.discussionId !== discussion.discussionId);
1393
+ for (const job of [...this.running.values()]) {
1394
+ if (job.discussionId === discussion.discussionId && !job.finished) this.abortJob(job, 'user');
1395
+ }
1396
+ this.settleDiscussionWaiters(discussion.discussionId);
1397
+ }
1398
+
1399
+ stopDiscussion(discussionId) {
1400
+ const discussion = this.repo.getDiscussion(discussionId);
1401
+ if (!discussion) throw Object.assign(new Error('讨论不存在'), { code: 'not-found' });
1402
+ this.admitConversation(discussion.groupId);
1403
+ this.assertCurrentTarget(discussion.groupId, discussion);
1404
+ if (discussion.protocolVersion === 2) {
1405
+ const stopped = isDiscussionActive(discussion);
1406
+ return this.controlDiscussion({ discussionId, operation: 'stop', expectedConversationId: discussion.conversationId })
1407
+ .then(() => ({ stopped }));
1408
+ }
1409
+ if (discussion.status !== 'running') return { stopped: false };
1410
+ discussion.status = 'stopped';
1411
+ discussion.stopReason = 'user';
1412
+ discussion.currentSpeaker = null;
1413
+ this.repo.putDiscussion(discussion);
1414
+ this.bus.emit(discussion.groupId, { type: 'discussion.updated', discussion });
1415
+ this.systemMessage(discussion.groupId, `讨论「${discussion.topic}」已停止。`);
1416
+ const keep = [];
1417
+ for (const queued of this.queue) {
1418
+ if (queued.discussionId === discussionId) {
1419
+ this.bus.emit(discussion.groupId, {
1420
+ type: 'member.status',
1421
+ membershipId: queued.membershipId,
1422
+ status: 'idle',
1423
+ });
1424
+ } else {
1425
+ keep.push(queued);
1426
+ }
1427
+ }
1428
+ this.queue = keep;
1429
+ for (const job of [...this.running.values()]) {
1430
+ if (job.discussionId === discussionId && !job.finished) this.abortJob(job, 'user');
1431
+ }
1432
+ this.settleDiscussionWaiters(discussionId);
1433
+ return { stopped: true };
1434
+ }
1435
+
1436
+ discussionTranscript(discussion) {
1437
+ return this.repo.messagesOf(discussion.groupId).filter(
1438
+ (message) =>
1439
+ this.isCurrentRecord(message, discussion.groupId) &&
1440
+ conversationIdOf(this.repo, message) === conversationIdOf(this.repo, discussion) &&
1441
+ message.meta &&
1442
+ message.meta.discussionId === discussion.discussionId &&
1443
+ message.author &&
1444
+ message.author.type === 'bot' &&
1445
+ (message.status === 'done' || message.status === 'error') &&
1446
+ String(message.text || '').trim().length > 0,
1447
+ );
1448
+ }
1449
+
1450
+ settleDiscussionWaiters(discussionId) {
1451
+ const waiters = this.discussionWaiters.get(discussionId);
1452
+ if (!waiters || waiters.length === 0) return;
1453
+ this.discussionWaiters.delete(discussionId);
1454
+ for (const entry of waiters) {
1455
+ clearTimeout(entry.timer);
1456
+ entry.resolve(true);
1457
+ }
1458
+ }
1459
+
1460
+ whenDiscussionSettled(discussionId, timeoutMs = 600000) {
1461
+ const discussion = this.repo.getDiscussion(discussionId);
1462
+ if (!discussion || !isDiscussionActive(discussion) || discussion.status === 'paused') return Promise.resolve(true);
1463
+ return new Promise((resolve) => {
1464
+ const entry = { resolve, timer: setTimeout(() => resolve(false), timeoutMs) };
1465
+ const list = this.discussionWaiters.get(discussionId) ?? [];
1466
+ list.push(entry);
1467
+ this.discussionWaiters.set(discussionId, list);
1468
+ });
1469
+ }
1470
+
1471
+ buildPrompt(job, bot, context) {
1472
+ this.assertJobCurrent(job);
1473
+ this.assertJobTargets(job);
1474
+ context = context.filter(message => this.isCurrentRecord(message, job.groupId) && message.seq > conversationFloor(this.repo, job.groupId));
1475
+ if (job.promptText) return job.promptText;
1476
+ if (job.kind === 'task') {
1477
+ const task = job.meta && job.meta.taskId ? this.repo.getTask(job.meta.taskId) : undefined;
1478
+ return buildTaskPrompt({
1479
+ repo: this.repo,
1480
+ botId: bot.botId,
1481
+ groupId: job.groupId,
1482
+ task: task || { title: '(任务已删除)', description: '', acceptance: '' },
1483
+ contextMessages: context,
1484
+ });
1485
+ }
1486
+ if (job.kind === 'task_revision') {
1487
+ const task = job.meta && job.meta.taskId ? this.repo.getTask(job.meta.taskId) : undefined;
1488
+ const review = task && task.reviewReplyMessageId ? this.repo.messages.get(task.reviewReplyMessageId) : undefined;
1489
+ return buildTaskRevisionPrompt({
1490
+ repo: this.repo,
1491
+ botId: bot.botId,
1492
+ groupId: job.groupId,
1493
+ task: task || { title: '(任务已删除)', deliverableMessageId: null, acceptance: '' },
1494
+ reviewMessage: review,
1495
+ contextMessages: context,
1496
+ });
1497
+ }
1498
+ if (job.kind === 'review') {
1499
+ const target = this.repo.messages.get(job.reviewTargetId);
1500
+ const requester = this.repo.messages.get(job.triggerMessageId);
1501
+ return buildReviewPrompt({
1502
+ repo: this.repo,
1503
+ botId: bot.botId,
1504
+ groupId: job.groupId,
1505
+ targetMessage: target,
1506
+ requesterName: requester ? this.authorNameOf(requester) : '用户',
1507
+ instructions: job.instructions || '',
1508
+ contextMessages: context,
1509
+ });
1510
+ }
1511
+ if (job.kind === 'revision') {
1512
+ const review = this.repo.messages.get(job.reviewMessageId);
1513
+ const target = review && review.meta && review.meta.reviewOf
1514
+ ? this.repo.messages.get(review.meta.reviewOf)
1515
+ : undefined;
1516
+ return buildRevisionPrompt({
1517
+ repo: this.repo,
1518
+ botId: bot.botId,
1519
+ groupId: job.groupId,
1520
+ targetMessage: target,
1521
+ reviewMessage: review,
1522
+ reviewerName: this.authorNameOf(review),
1523
+ contextMessages: context,
1524
+ });
1525
+ }
1526
+ if (job.kind === 'discussion') {
1527
+ const discussion = this.repo.getDiscussion(job.discussionId);
1528
+ return buildDiscussionPrompt({
1529
+ repo: this.repo,
1530
+ botId: bot.botId,
1531
+ groupId: job.groupId,
1532
+ discussion: discussion || { topic: '(已删除的讨论)', rounds: job.round ?? 1 },
1533
+ round: job.round ?? 1,
1534
+ transcript: discussion ? this.discussionTranscript(discussion) : [],
1535
+ });
1536
+ }
1537
+ if (job.kind === 'summary') {
1538
+ const discussion = this.repo.getDiscussion(job.discussionId);
1539
+ return buildSummaryPrompt({
1540
+ repo: this.repo,
1541
+ botId: bot.botId,
1542
+ groupId: job.groupId,
1543
+ discussion: discussion || { topic: '(已删除的讨论)', rounds: job.round ?? 1 },
1544
+ transcript: discussion ? this.discussionTranscript(discussion) : [],
1545
+ });
1546
+ }
1547
+ return buildTurnPrompt({
1548
+ repo: this.repo,
1549
+ botId: bot.botId,
1550
+ groupId: job.groupId,
1551
+ contextMessages: context,
1552
+ triggeredByMessageId: job.triggerMessageId,
1553
+ mention: true,
1554
+ });
1555
+ }
1556
+
1557
+ /** Interrupt every running/queued turn that belongs to one task. */
1558
+ cancelTaskRun(taskId) {
1559
+ let aborted = 0;
1560
+ let dequeued = 0;
1561
+ const groups = new Set();
1562
+ for (const job of [...this.running.values()]) {
1563
+ if (!job.finished && job.meta && job.meta.taskId === taskId) {
1564
+ groups.add(job.groupId);
1565
+ this.abortJob(job, 'user');
1566
+ aborted += 1;
1567
+ }
1568
+ }
1569
+ const kept = [];
1570
+ for (const queued of this.queue) {
1571
+ if (queued.meta && queued.meta.taskId === taskId) {
1572
+ groups.add(queued.groupId);
1573
+ dequeued += 1;
1574
+ } else {
1575
+ kept.push(queued);
1576
+ }
1577
+ }
1578
+ this.queue = kept;
1579
+ for (const groupId of groups) this.pump(groupId);
1580
+ return { aborted, dequeued };
1581
+ }
1582
+
1583
+ stopMember(membershipId) {
1584
+ const job = this.running.get(membershipId);
1585
+ const affected = new Set([job?.groupId, ...this.queue.filter(item => item.membershipId === membershipId).map(item => item.groupId)].filter(Boolean));
1586
+ for (const groupId of affected) this.pumpBlocks.add(groupId);
1587
+ let stopped = 0;
1588
+ const discussions = new Set([job?.discussionId, ...this.queue.filter(item => item.membershipId === membershipId).map(item => item.discussionId)].filter(Boolean));
1589
+ for (const id of discussions) {
1590
+ const discussion = this.repo.getDiscussion(id);
1591
+ if (discussion?.protocolVersion === 2) void this.discussionRunner.interrupt(id).catch(error => this.discussionRunner.failed(id, error));
1592
+ }
1593
+ // Drain first: abortJob -> finishJob may synchronously pump another job.
1594
+ this.queue = this.queue.filter(queued => {
1595
+ if (queued.membershipId !== membershipId) return true;
1596
+ stopped += 1;
1597
+ this.bus.emit(queued.groupId, { type: 'member.status', membershipId, status: 'idle' });
1598
+ return false;
1599
+ });
1600
+ if (job && !job.finished) { this.abortJob(job, 'user'); stopped += 1; }
1601
+ for (const groupId of affected) { this.pumpBlocks.delete(groupId); this.pump(groupId); this.checkIdle(groupId); }
1602
+ return { stopped };
1603
+ }
1604
+
1605
+ stopGroup(groupId) {
1606
+ this.pumpBlocks.add(groupId);
1607
+ let stopped = 0;
1608
+ // Close all discussion schedulers before clearing queues or cancelling jobs.
1609
+ for (const discussion of this.repo.discussionsOf(groupId)) {
1610
+ if (!isDiscussionActive(discussion) || !this.isCurrentRecord(discussion, groupId)) continue;
1611
+ if (discussion.protocolVersion === 2) {
1612
+ void this.discussionRunner.interrupt(discussion.discussionId, { stop: true }).catch(error => this.discussionRunner.failed(discussion.discussionId, error));
1613
+ } else {
1614
+ discussion.status = 'stopped'; discussion.stopReason = 'user'; discussion.currentSpeaker = null;
1615
+ this.repo.putDiscussion(discussion);
1616
+ this.bus.emit(groupId, { type: 'discussion.updated', discussion });
1617
+ this.settleDiscussionWaiters(discussion.discussionId);
1618
+ }
1619
+ }
1620
+ this.queue = this.queue.filter(job => {
1621
+ if (job.groupId !== groupId) return true;
1622
+ stopped += 1;
1623
+ this.bus.emit(groupId, { type: 'member.status', membershipId: job.membershipId, status: 'idle' });
1624
+ return false;
1625
+ });
1626
+ for (const job of [...this.running.values()]) {
1627
+ if (job.groupId !== groupId || job.finished) continue;
1628
+ this.abortJob(job, 'user'); stopped += 1;
1629
+ }
1630
+ this.pumpBlocks.delete(groupId);
1631
+ this.checkIdle(groupId);
1632
+ return { stopped };
1633
+ }
1634
+
1635
+ checkIdle(groupId) {
1636
+ if (this.runningCount(groupId) > 0 || this.queuedCount(groupId) > 0) return;
1637
+ const waiters = this.waiters.get(groupId);
1638
+ if (!waiters || waiters.length === 0) return;
1639
+ this.waiters.delete(groupId);
1640
+ for (const entry of waiters) {
1641
+ clearTimeout(entry.timer);
1642
+ entry.resolve(true);
1643
+ }
1644
+ }
1645
+
1646
+ whenIdle(groupId, timeoutMs = 180000) {
1647
+ if (this.runningCount(groupId) === 0 && this.queuedCount(groupId) === 0) return Promise.resolve(true);
1648
+ return new Promise((resolve) => {
1649
+ const entry = { resolve, timer: setTimeout(() => resolve(false), timeoutMs) };
1650
+ const list = this.waiters.get(groupId) ?? [];
1651
+ list.push(entry);
1652
+ this.waiters.set(groupId, list);
1653
+ });
1654
+ }
1655
+
1656
+ hasPendingWork(groupId) {
1657
+ return [...this.pendingWork].some((operation) => operation.groupId === groupId);
1658
+ }
1659
+
1660
+ isRunning(groupId) {
1661
+ return this.runningCount(groupId) > 0 || this.queuedCount(groupId) > 0;
1662
+ }
1663
+
1664
+ async dispose() {
1665
+ this.disposed = true;
1666
+ try {
1667
+ this.offSession?.();
1668
+ this.offClaim?.();
1669
+ this.offGoal?.();
1670
+ } catch {
1671
+ /* noop */
1672
+ }
1673
+ this.queue = [];
1674
+ for (const job of [...this.running.values()]) {
1675
+ try {
1676
+ this.abortJob(job, 'dispose');
1677
+ } catch {
1678
+ /* noop */
1679
+ }
1680
+ }
1681
+ for (const [groupId, waiters] of this.waiters) {
1682
+ for (const entry of waiters) {
1683
+ clearTimeout(entry.timer);
1684
+ entry.resolve(false);
1685
+ }
1686
+ this.waiters.delete(groupId);
1687
+ }
1688
+ for (const [discussionId, waiters] of this.discussionWaiters) {
1689
+ for (const entry of waiters) {
1690
+ clearTimeout(entry.timer);
1691
+ entry.resolve(false);
1692
+ }
1693
+ this.discussionWaiters.delete(discussionId);
1694
+ }
1695
+ // Give aborted turns a short window to settle before agents are disposed.
1696
+ await withTimeout(Promise.resolve(), 500, true);
1697
+ }
1698
+ }