dsh-bot-team 1.1.0 → 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 CHANGED
@@ -23,14 +23,17 @@ import {
23
23
  parseReviewVerdict,
24
24
  } from './prompt.js';
25
25
  import { newId, now, withTimeout } from './util.js';
26
+ import { DiscussionRunner } from './discussion-runner.js';
27
+ import { isDiscussionActive } from './discussion-flow.js';
26
28
  import { LastRequestMetrics } from './message-metrics.js';
29
+ import { currentConversationId, conversationFloor, conversationIdOf, isCurrentMemberSession } from './conversations.js';
27
30
 
28
31
  const executionSeq = (value) => Number.isSafeInteger(value) && value >= 0;
29
32
  const executionTurn = (value) => Number.isSafeInteger(value) && value > 0;
30
- const executionEvent = (type) => /^(?:turn|step|user|assistant|tool|tools|ptc|approval|llm|request|compaction)\//.test(type);
33
+ const executionEvent = (type) => typeof type === 'string';
31
34
 
32
35
  export class Engine {
33
- constructor({ ctx, repo, pool, personas, bus, config, logger, quotaGuard, taskHook }) {
36
+ constructor({ ctx, repo, pool, personas, bus, config, logger, quotaGuard, taskHook, workspaceGuard, assertAdmission, discussionGuard }) {
34
37
  this.ctx = ctx;
35
38
  this.repo = repo;
36
39
  this.pool = pool;
@@ -40,6 +43,12 @@ export class Engine {
40
43
  this.logger = logger;
41
44
  this.quotaGuard = quotaGuard || (() => ({ ok: true }));
42
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();
43
52
 
44
53
  this.queue = [];
45
54
  this.running = new Map(); // membershipId -> job
@@ -48,16 +57,150 @@ export class Engine {
48
57
  this.discussionWaiters = new Map(); // discussionId -> [{ resolve }]
49
58
  this.disposed = false;
50
59
 
51
- this.offSession = ctx.on('session/event', (session, event) => this.onSessionEvent(session, event));
60
+ this.offSession = ctx.on('session/event', (session, event) => this.onSessionEvent(session, event), { global: true });
52
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
+ };
53
190
  }
54
191
 
55
192
  // ── message helpers ──────────────────────────────────────────────────────
56
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
+ }
57
198
  const seq = this.repo.allocateSeq(fields.groupId);
58
199
  const message = {
59
200
  messageId: newId('msg'),
60
201
  groupId: fields.groupId,
202
+ conversationId,
203
+ ...(fields.quote ? { quote: structuredClone(fields.quote) } : {}),
61
204
  seq,
62
205
  author: fields.author,
63
206
  kind: fields.kind ?? 'chat',
@@ -98,6 +241,7 @@ export class Engine {
98
241
  * points the user there and tracks the outcome.
99
242
  */
100
243
  approvalMessage(job, data) {
244
+ if (!this.isCurrentJob(job)) return;
101
245
  const member = this.repo.getMember(job.membershipId);
102
246
  const bot = member ? this.repo.getBot(member.botId) : undefined;
103
247
  const name = bot?.name || 'Bot';
@@ -105,6 +249,7 @@ export class Engine {
105
249
  const text = `${name} 请求权限:工具 ${data.toolName}${reason ? `(${reason})` : ''}。`;
106
250
  const message = this.createMessage({
107
251
  groupId: job.groupId,
252
+ conversationId: job.conversationId,
108
253
  author: { type: 'system', name: '审批' },
109
254
  text,
110
255
  status: 'done',
@@ -114,6 +259,7 @@ export class Engine {
114
259
  membershipId: job.membershipId,
115
260
  botId: member?.botId ?? null,
116
261
  sessionId: job.sessionId,
262
+ nativeSession: true,
117
263
  toolName: String(data.toolName || ''),
118
264
  reason,
119
265
  approvalState: 'pending',
@@ -137,25 +283,29 @@ export class Engine {
137
283
  }
138
284
 
139
285
  // ── public dispatch ──────────────────────────────────────────────────────
140
- assertQuota(groupId, turns = 1) {
141
- const result = this.quotaGuard(groupId, turns);
286
+ assertQuota(groupId) {
287
+ this.assertAdmission(groupId);
288
+ const result = this.quotaGuard(groupId);
142
289
  if (!result || result.ok) return;
143
290
  const error = new Error(result.reason || '本群额度已用完,请稍后再试');
144
291
  error.code = 'quota';
145
292
  throw error;
146
293
  }
147
294
 
148
- postHumanMessage({ groupId, text, mentions, author, replyTo, attachments }) {
295
+ postHumanMessage({ groupId, text, mentions = [], author, replyTo, attachments, humanInput = true, expectedConversationId }) {
296
+ const conversationId = this.admitConversation(groupId, expectedConversationId);
149
297
  const group = this.repo.getGroup(groupId);
150
- if (!group) throw new Error(`group ${groupId} does not exist`);
151
- let replyToId = null;
152
- if (replyTo) {
153
- const target = this.repo.getMessage(replyTo);
154
- if (!target || target.groupId !== groupId) {
155
- throw Object.assign(new Error('回复的原消息不存在或不属于本群'), { code: 'invalid-input' });
156
- }
157
- replyToId = target.messageId;
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' });
158
307
  }
308
+ const replyToId = quote?.messageId || null;
159
309
  const message = this.createMessage({
160
310
  groupId,
161
311
  author: author || { type: 'human', name: '用户' },
@@ -163,6 +313,8 @@ export class Engine {
163
313
  mentions,
164
314
  attachments,
165
315
  replyTo: replyToId,
316
+ conversationId,
317
+ quote,
166
318
  status: 'done',
167
319
  });
168
320
  this.bus.emit(groupId, { type: 'message.added', message });
@@ -174,15 +326,17 @@ export class Engine {
174
326
  if (!this.repo.getBot(member.botId)) continue;
175
327
  if (!targets.some((candidate) => candidate.membershipId === member.membershipId)) targets.push(member);
176
328
  }
177
- if (targets.length > 0) this.assertQuota(groupId, targets.length);
329
+ if (targets.length > 0) this.assertQuota(groupId);
178
330
  for (const member of targets) {
179
331
  this.queue.push({
180
332
  jobId: newId('job'),
181
333
  groupId,
334
+ conversationId,
182
335
  membershipId: member.membershipId,
183
336
  triggerSeq: message.seq,
184
337
  triggerMessageId: message.messageId,
185
338
  kind: 'chat',
339
+ humanInput,
186
340
  state: 'queued',
187
341
  });
188
342
  }
@@ -191,7 +345,7 @@ export class Engine {
191
345
  }
192
346
 
193
347
  pump(groupId) {
194
- if (this.disposed) return;
348
+ if (this.disposed || this.pumpBlocks.has(groupId)) return;
195
349
  const runnable = [];
196
350
  const busy = new Set(
197
351
  [...this.running.values()].filter((job) => job.groupId === groupId).map((job) => job.membershipId),
@@ -199,6 +353,10 @@ export class Engine {
199
353
  let running = this.runningCount(groupId);
200
354
  for (const job of this.queue) {
201
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
+ }
202
360
  if (running >= this.config.maxParallelTurns) break;
203
361
  if (busy.has(job.membershipId)) continue;
204
362
  busy.add(job.membershipId);
@@ -222,7 +380,24 @@ export class Engine {
222
380
  return this.queue.filter((job) => job.groupId === groupId).length;
223
381
  }
224
382
 
225
- startJob(job) {
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
+ }
226
401
  job.state = 'running';
227
402
  job.turn = null;
228
403
  job.submitted = false;
@@ -242,6 +417,9 @@ export class Engine {
242
417
  job.streamText = '';
243
418
  job.requestMetrics = new LastRequestMetrics();
244
419
  job.finished = false;
420
+ job.following = [];
421
+ job.awaitingContinuation = false;
422
+ job.nativeObserved = observed !== null;
245
423
  job.done = new Promise((resolve) => {
246
424
  job.resolveDone = resolve;
247
425
  });
@@ -253,8 +431,9 @@ export class Engine {
253
431
  this.finishJob(job, 'error', '成员或 Bot 已不存在');
254
432
  return;
255
433
  }
256
- const placeholder = this.createMessage({
434
+ const placeholder = observed?.message || this.createMessage({
257
435
  groupId: job.groupId,
436
+ conversationId: job.conversationId,
258
437
  author: {
259
438
  type: 'bot',
260
439
  botId: member.botId,
@@ -271,34 +450,59 @@ export class Engine {
271
450
  meta: { ...(job.meta || {}), execution: job.execution },
272
451
  });
273
452
  job.placeholderId = placeholder.messageId;
274
- this.bus.emit(job.groupId, { type: 'message.added', message: placeholder });
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 });
275
458
  this.bus.emit(job.groupId, {
276
459
  type: 'member.status',
460
+ conversationId: job.conversationId,
277
461
  membershipId: member.membershipId,
278
462
  status: 'running',
279
463
  });
280
464
 
281
- const previousSeen = Number.isInteger(member.lastSeenSeq) ? member.lastSeenSeq : 0;
282
- const context = this.repo
283
- .messagesBetween(job.groupId, previousSeen, job.triggerSeq)
284
- .filter((message) => message.messageId !== placeholder.messageId);
285
- member.lastSeenSeq = job.triggerSeq;
286
- this.repo.touchMember(member);
465
+ const context = this.contextFor(job, member);
287
466
  job.context = context;
288
467
 
289
- job.timer = setTimeout(() => this.abortJob(job, 'timeout'), this.config.turnTimeoutMs);
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);
290
477
  void this.runJob(job, member, bot, context);
291
478
  }
292
479
 
293
480
  async runJob(job, member, bot, context) {
481
+ this.pendingWork.add(job);
294
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;
295
489
  const agent = await this.pool.ensure(member.membershipId);
296
490
  if (job.finished) return;
297
491
  if (this.disposed || job.state === 'stopped') {
298
492
  this.finishJob(job, 'stopped', '已停止');
299
493
  return;
300
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);
301
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;
302
506
  this.activeBySession.set(agent.id, job);
303
507
  await this.persistExecution(job, { sessionId: agent.id });
304
508
  if (job.finished) return;
@@ -310,7 +514,11 @@ export class Engine {
310
514
  }
311
515
 
312
516
  if (job.finished) return;
313
- const prompt = this.buildPrompt(job, bot, context);
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);
314
522
  job.promptPreview = prompt.slice(0, 2000);
315
523
  const inputMessageId = newId('umsg');
316
524
  // Session.seq is the next accepted event index, an inclusive search floor.
@@ -322,21 +530,42 @@ export class Engine {
322
530
  // A stop/timeout may have occurred while the durable write was pending.
323
531
  if (job.finished) return;
324
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
+
325
539
  // M0 P1: followup puts the EXACT value into the durable inbox — it must be
326
540
  // a complete UserMessage (role + id + content + source). Claims may fire
327
541
  // synchronously, so arm correlation only after the write has succeeded.
328
542
  job.submitted = true;
329
- await agent.followup({
543
+ const input = {
330
544
  role: 'user',
331
545
  id: inputMessageId,
332
546
  content: [{ type: 'text', text: prompt }],
333
- source: { kind: 'user' },
334
- });
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);
335
559
  await job.done;
336
560
  } catch (error) {
337
561
  this.finishJob(job, 'error', String(error && error.message ? error.message : error));
338
562
  } finally {
339
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
+ }
340
569
  }
341
570
  }
342
571
 
@@ -344,12 +573,15 @@ export class Engine {
344
573
  this.bus.emit(job.groupId, {
345
574
  type: 'execution.updated',
346
575
  messageId: job.placeholderId,
576
+ conversationId: job.conversationId,
577
+ sessionId: job.execution.sessionId,
347
578
  seq: executionSeq(seq) ? seq : null,
348
579
  ...(includeExecution ? { execution: { ...job.execution } } : {}),
349
580
  });
350
581
  }
351
582
 
352
583
  async persistExecution(job, patch, seq = null) {
584
+ this.assertJobCurrent(job);
353
585
  const message = this.repo.messages.get(job.placeholderId);
354
586
  if (!message) throw new Error('无法保存执行记录:回复消息不存在');
355
587
  job.execution = { ...job.execution, ...patch };
@@ -357,16 +589,22 @@ export class Engine {
357
589
  message.updatedAt = now();
358
590
  // Do not use putMessage here: it deliberately swallows storage failures.
359
591
  const saved = this.repo.putMessageStrict(message);
360
- this.emitExecutionUpdated(job, seq, true);
361
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));
362
599
  }
363
600
 
364
601
  executionFailed(job, error) {
365
602
  if (job.finished) return;
603
+ const scoped = this.activeBySession.get(job.sessionId) === job && (!job.stepId || job.turn !== null);
366
604
  this.finishJob(job, 'error', `无法保存执行记录:${String(error?.message || error)}`);
367
- if (job.submitted && job.execution.endSeq === null) {
605
+ if (scoped && !job.nativeObserved && job.submitted && job.execution.endSeq === null) {
368
606
  try {
369
- this.ctx.agents.get(job.sessionId)?.cancel?.({ kind: 'user' }, { keepInbox: false });
607
+ this.ctx.agents.get(job.sessionId)?.cancel?.({ kind: 'user' }, { keepInbox: Boolean(job.stepId) });
370
608
  } catch (cancelError) {
371
609
  this.logger?.warn?.(`bot-team: cancel agent failed: ${String(cancelError)}`);
372
610
  }
@@ -386,17 +624,165 @@ export class Engine {
386
624
 
387
625
  onInboxClaim(payload) {
388
626
  const job = this.activeBySession.get(payload?.agent?.id);
389
- if (!job || job.finished || job.ending || !job.submitted) return;
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;
390
629
  if (!job.execution.inputMessageId || payload.message?.id !== job.execution.inputMessageId) return;
391
630
  this.bindExecutionTurn(job, payload.turn);
392
631
  }
393
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
+
394
765
  onSessionEvent(session, event) {
395
766
  if (!session || !event) return;
396
- const job = this.activeBySession.get(session.id);
397
- if (!job || job.finished || job.ending) 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; }
398
774
  const data = event.data || {};
399
- const correlated = Boolean(job.execution.inputMessageId);
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;
400
786
  if (correlated && !job.submitted) return;
401
787
  if (executionSeq(event.seq) && executionSeq(job.execution.submittedSeq) && event.seq < job.execution.submittedSeq) return;
402
788
 
@@ -425,7 +811,11 @@ export class Engine {
425
811
  return;
426
812
  }
427
813
  if (event.type === 'user/message' && correlated) {
428
- if ((data.id ?? data.message?.id) !== job.execution.inputMessageId) return;
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;
429
819
  // Accepted user/message contains the UserMessage itself, without a turn.
430
820
  // Its exact ID disambiguates the enclosing turn/start candidate.
431
821
  this.bindExecutionTurn(job, executionTurn(data.turn) ? data.turn : job.observedTurn);
@@ -464,6 +854,7 @@ export class Engine {
464
854
  if (combined) {
465
855
  this.bus.emit(job.groupId, {
466
856
  type: 'message.replace',
857
+ conversationId: job.conversationId,
467
858
  messageId: job.placeholderId,
468
859
  text: combined,
469
860
  });
@@ -474,7 +865,16 @@ export class Engine {
474
865
  }
475
866
 
476
867
  finishTurn(job, reason = {}) {
868
+ if (job.finished) return;
869
+ if (!this.isCurrentJob(job)) { this.finishJob(job, 'stopped', '本次执行所属会话已结束'); return; }
870
+ job.ending = false;
477
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
+ }
478
878
  this.finishJob(job, 'done');
479
879
  } else if (reason.kind === 'aborted') {
480
880
  this.finishJob(job, 'stopped', reason.reason && reason.reason.kind === 'user' ? '已停止' : '已中断');
@@ -488,8 +888,11 @@ export class Engine {
488
888
 
489
889
  onAssistantStream(payload) {
490
890
  if (!payload || !payload.agent || !payload.frame) return;
891
+ if (this.ctx.agents.get(payload.agent.id) !== payload.agent) return;
491
892
  const job = this.activeBySession.get(payload.agent.id);
492
- if (!job || job.finished || job.ending || job.turn === null || job.observedTurn !== job.turn) return;
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;
493
896
  const frame = payload.frame;
494
897
  if (executionTurn(frame.turn) && frame.turn !== job.turn) return;
495
898
  if (frame.type === 'start') {
@@ -513,41 +916,61 @@ export class Engine {
513
916
  job.streamText += frame.chunk.text;
514
917
  this.bus.emit(job.groupId, {
515
918
  type: 'message.delta',
919
+ conversationId: job.conversationId,
516
920
  messageId: job.placeholderId,
517
921
  chunk: frame.chunk.text,
518
922
  });
519
923
  }
520
924
  }
521
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
+
522
948
  abortJob(job, cause) {
523
949
  if (!job || job.finished) return;
524
950
  job.state = 'stopped';
951
+ this.withdrawDiscussionInput(job);
525
952
  try {
526
953
  job.abortController?.abort();
527
954
  } catch {
528
955
  /* noop */
529
956
  }
530
957
  const agent = job.sessionId ? this.ctx.agents.get(job.sessionId) : undefined;
531
- if (agent && typeof agent.cancel === 'function') {
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') {
532
961
  try {
533
- agent.cancel({ kind: 'user' }, { keepInbox: false });
962
+ // A shared native session may contain unrelated queued inputs.
963
+ agent.cancel({ kind: 'user' }, { keepInbox: Boolean(job.stepId) });
534
964
  } catch (error) {
535
965
  this.logger?.warn?.(`bot-team: cancel agent failed: ${String(error)}`);
536
966
  }
537
967
  }
538
- const reason = cause === 'timeout' ? '本轮超时,已停止' : cause === 'dispose' ? '插件已卸载' : '已停止';
968
+ const reason = cause === 'timeout' ? '本轮超时,已停止' : cause === 'dispose' ? (owned ? 'Bot Team 已卸载,后台执行已停止' : 'Bot Team 已断开观察;原生会话仍由 DSH 管理') : '已停止';
539
969
  this.finishJob(job, 'stopped', reason);
540
970
  }
541
971
 
542
- finishJob(job, status, error) {
543
- if (!job || job.finished) return;
544
- job.finished = true;
545
- job.state = status;
546
- if (job.timer) clearTimeout(job.timer);
547
- if (job.sessionId) this.activeBySession.delete(job.sessionId);
548
- this.running.delete(job.membershipId);
549
-
550
- const text = (job.steps.filter(Boolean).join('\n\n') || job.streamText || '').trim();
972
+ settleMessage(job, status, error) {
973
+ const text = ((job.steps || []).filter(Boolean).join('\n\n') || job.streamText || '').trim();
551
974
  const message = job.placeholderId ? this.repo.messages.get(job.placeholderId) : undefined;
552
975
  if (message) {
553
976
  message.text = text;
@@ -556,57 +979,89 @@ export class Engine {
556
979
  message.usage = job.usage ?? null;
557
980
  message.generationStats = job.requestMetrics?.finish(status) ?? null;
558
981
  message.kind = job.messageKind ?? message.kind ?? 'chat';
559
- const meta = { ...(message.meta || {}), ...(job.meta || {}), execution: job.execution };
982
+ const meta = { ...(message.meta || {}), ...(job.meta || {}), execution: job.execution,
983
+ ...(job.stepId ? { logicalJobComplete: job.finished === true } : {}) };
560
984
  if (job.kind === 'review' && status === 'done') {
561
985
  const verdict = parseReviewVerdict(text);
562
986
  if (verdict) meta.verdict = verdict;
563
987
  }
564
988
  if (Object.keys(meta).length > 0) message.meta = meta;
565
989
  message.updatedAt = now();
566
- this.repo.putMessage(message);
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);
567
996
  this.bus.emit(job.groupId, { type: 'message.updated', message });
568
997
  // The accepted end event may have invalidated before this final status.
569
998
  // A null seq also refreshes stopped jobs and bypasses event deduplication.
570
999
  this.emitExecutionUpdated(job, null, true);
571
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);
572
1015
  if (job.approvalMessages && job.approvalMessages.size > 0) {
573
1016
  for (const approvalId of [...job.approvalMessages.keys()]) {
574
1017
  this.settleApproval(job, approvalId, 'cancelled');
575
1018
  }
576
1019
  }
577
- this.bus.emit(job.groupId, {
1020
+ if (current) this.bus.emit(job.groupId, {
578
1021
  type: 'member.status',
1022
+ conversationId: job.conversationId,
579
1023
  membershipId: job.membershipId,
580
1024
  status: status === 'done' ? 'idle' : status === 'stopped' ? 'idle' : 'error',
581
1025
  ...(status === 'error' ? { error: error ?? '未知错误' } : {}),
582
1026
  });
583
1027
  try {
584
- const hookResult = this.taskHook(job, message, status);
1028
+ const hookResult = current ? this.taskHook(job, message, status) : undefined;
585
1029
  if (hookResult && typeof hookResult.then === 'function') {
1030
+ const pendingHook = { groupId: job.groupId, conversationId: job.conversationId };
1031
+ this.pendingWork.add(pendingHook);
586
1032
  hookResult.catch((hookError) => {
587
1033
  this.logger?.warn?.('bot-team: task hook failed: ' + String(hookError));
588
- });
1034
+ }).finally(() => this.pendingWork.delete(pendingHook));
589
1035
  }
590
1036
  } catch (hookError) {
591
1037
  this.logger?.warn?.('bot-team: task hook failed: ' + String(hookError));
592
1038
  }
593
- job.resolveDone?.();
594
- job.state = status;
595
- this.pump(job.groupId);
596
- this.afterJobFinished(job);
597
- this.checkIdle(job.groupId);
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
+ }
598
1053
  }
599
1054
 
600
1055
  /** Discussion bookkeeping + review-side effects after one job settles. */
601
1056
  afterJobFinished(job) {
602
- if (!job) return;
1057
+ if (!job || !this.isCurrentJob(job)) return;
603
1058
  if (job.kind === 'discussion' && job.discussionId) {
604
1059
  this.advanceDiscussion(job.discussionId, job.state === 'done');
605
1060
  return;
606
1061
  }
607
1062
  if (job.kind === 'summary' && job.discussionId) {
608
1063
  const discussion = this.repo.getDiscussion(job.discussionId);
609
- if (discussion && discussion.status === 'running') {
1064
+ if (discussion && discussion.status === 'running' && this.isCurrentRecord(discussion)) {
610
1065
  discussion.status = job.state === 'done' ? 'done' : 'stopped';
611
1066
  discussion.updatedAt = now();
612
1067
  this.repo.putDiscussion(discussion);
@@ -624,7 +1079,7 @@ export class Engine {
624
1079
 
625
1080
  advanceDiscussion(discussionId, ok) {
626
1081
  const discussion = this.repo.getDiscussion(discussionId);
627
- if (!discussion || discussion.status !== 'running') return;
1082
+ if (!discussion || discussion.status !== 'running' || !this.isCurrentRecord(discussion)) return;
628
1083
  if (!ok) {
629
1084
  discussion.status = 'stopped';
630
1085
  discussion.stopReason = 'member-error';
@@ -632,6 +1087,7 @@ export class Engine {
632
1087
  this.repo.putDiscussion(discussion);
633
1088
  this.bus.emit(discussion.groupId, { type: 'discussion.updated', discussion });
634
1089
  this.systemMessage(discussion.groupId, `讨论「${discussion.topic}」因成员执行失败而停止。`);
1090
+ this.settleDiscussionWaiters(discussionId);
635
1091
  return;
636
1092
  }
637
1093
  discussion.currentIndex = (discussion.currentIndex ?? 0) + 1;
@@ -647,14 +1103,17 @@ export class Engine {
647
1103
  }
648
1104
 
649
1105
  /** Enqueue one task delivery turn for the task's assignee. */
650
- enqueueTaskTurn({ taskId, phase }) {
1106
+ enqueueTaskTurn({ taskId, phase, expectedConversationId }) {
651
1107
  const task = this.repo.getTask(taskId);
652
1108
  if (!task) throw Object.assign(new Error('任务不存在'), { code: 'not-found' });
653
1109
  const group = this.repo.getGroup(task.groupId);
654
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);
655
1113
  const triggerSeq = this.repo.lastMessageOf(task.groupId)?.seq ?? 0;
656
1114
  this.enqueueJob({
657
1115
  groupId: task.groupId,
1116
+ conversationId,
658
1117
  membershipId: task.assigneeMembershipId,
659
1118
  kind: phase === 'revision' ? 'task_revision' : 'task',
660
1119
  triggerSeq,
@@ -679,10 +1138,13 @@ export class Engine {
679
1138
 
680
1139
  // ── M2: review / revision ───────────────────────────────────────────────
681
1140
  enqueueJob(spec) {
1141
+ const conversationId = this.admitConversation(spec.groupId, spec.conversationId ?? spec.expectedConversationId);
1142
+ this.assertJobTargets(spec);
682
1143
  const job = {
683
1144
  jobId: newId('job'),
684
1145
  state: 'queued',
685
1146
  ...spec,
1147
+ conversationId,
686
1148
  };
687
1149
  this.queue.push(job);
688
1150
  this.pump(job.groupId);
@@ -697,14 +1159,17 @@ export class Engine {
697
1159
  return bot?.name ?? message.author?.name ?? 'Bot';
698
1160
  }
699
1161
 
700
- requestReview({ groupId, targetMessageId, reviewerMembershipId, instructions, taskId }) {
1162
+ requestReview({ groupId, targetMessageId, reviewerMembershipId, instructions, taskId, expectedConversationId }) {
1163
+ const conversationId = this.admitConversation(groupId, expectedConversationId);
701
1164
  const target = this.repo.messages.get(targetMessageId);
702
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));
703
1168
  const member = this.repo.getMember(reviewerMembershipId);
704
1169
  if (!member || member.groupId !== groupId) throw Object.assign(new Error('评审者不在群里'), { code: 'invalid-input' });
705
1170
  const reviewer = this.repo.getBot(member.botId);
706
1171
  if (!reviewer) throw Object.assign(new Error('评审者 Bot 不存在'), { code: 'not-found' });
707
- this.assertQuota(groupId, 1);
1172
+ this.assertQuota(groupId);
708
1173
  const requestMessage = this.createMessage({
709
1174
  groupId,
710
1175
  author: { type: 'system', name: '系统' },
@@ -718,6 +1183,7 @@ export class Engine {
718
1183
  this.bus.emit(groupId, { type: 'message.added', message: requestMessage });
719
1184
  this.enqueueJob({
720
1185
  groupId,
1186
+ conversationId,
721
1187
  membershipId: reviewerMembershipId,
722
1188
  kind: 'review',
723
1189
  triggerSeq: requestMessage.seq,
@@ -730,12 +1196,15 @@ export class Engine {
730
1196
  return { requestMessage, reviewer: reviewer.name };
731
1197
  }
732
1198
 
733
- requestRevision({ groupId, reviewMessageId }) {
1199
+ requestRevision({ groupId, reviewMessageId, expectedConversationId }) {
1200
+ const conversationId = this.admitConversation(groupId, expectedConversationId);
734
1201
  const review = this.repo.messages.get(reviewMessageId);
735
1202
  if (!review || review.groupId !== groupId) throw Object.assign(new Error('评审消息不存在'), { code: 'not-found' });
1203
+ this.assertCurrentTarget(groupId, review);
736
1204
  const targetId = review.meta && review.meta.reviewOf;
737
1205
  const target = targetId ? this.repo.messages.get(targetId) : undefined;
738
1206
  if (!target) throw Object.assign(new Error('找不到被评审的原消息'), { code: 'not-found' });
1207
+ this.assertCurrentTarget(groupId, target);
739
1208
  const membershipId = target.author && target.author.membershipId;
740
1209
  if (!membershipId || target.author.type !== 'bot') {
741
1210
  throw Object.assign(new Error('只有 Bot 的消息可以要求作者修改'), { code: 'invalid-input' });
@@ -743,7 +1212,7 @@ export class Engine {
743
1212
  const member = this.repo.getMember(membershipId);
744
1213
  const bot = member ? this.repo.getBot(member.botId) : undefined;
745
1214
  if (!member || !bot) throw Object.assign(new Error('原消息作者已不在群里'), { code: 'not-found' });
746
- this.assertQuota(groupId, 1);
1215
+ this.assertQuota(groupId);
747
1216
  const reviewerName = this.authorNameOf(review);
748
1217
  const requestMessage = this.createMessage({
749
1218
  groupId,
@@ -758,6 +1227,7 @@ export class Engine {
758
1227
  this.bus.emit(groupId, { type: 'message.added', message: requestMessage });
759
1228
  this.enqueueJob({
760
1229
  groupId,
1230
+ conversationId,
761
1231
  membershipId,
762
1232
  kind: 'revision',
763
1233
  triggerSeq: requestMessage.seq,
@@ -769,13 +1239,15 @@ export class Engine {
769
1239
  return { requestMessage, author: bot.name };
770
1240
  }
771
1241
 
772
- retryMessage(messageId) {
1242
+ retryMessage(messageId, expectedConversationId) {
773
1243
  const message = this.repo.messages.get(messageId);
774
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);
775
1247
  if (message.author?.type !== 'bot') throw Object.assign(new Error('只能重试 Bot 的回复'), { code: 'invalid-input' });
776
1248
  const member = this.repo.getMember(message.author.membershipId);
777
1249
  if (!member) throw Object.assign(new Error('该 Bot 已不在群里'), { code: 'not-found' });
778
- this.assertQuota(message.groupId, 1);
1250
+ this.assertQuota(message.groupId);
779
1251
  const meta = message.meta || {};
780
1252
  if (message.kind === 'discussion') {
781
1253
  throw Object.assign(new Error('讨论消息不支持单条重试,请重新发起讨论'), { code: 'invalid-input' });
@@ -783,6 +1255,7 @@ export class Engine {
783
1255
  if (message.kind === 'review' && meta.reviewOf) {
784
1256
  return this.enqueueJob({
785
1257
  groupId: message.groupId,
1258
+ conversationId,
786
1259
  membershipId: member.membershipId,
787
1260
  kind: 'review',
788
1261
  triggerSeq: message.seq,
@@ -796,6 +1269,7 @@ export class Engine {
796
1269
  if (message.kind === 'revision' && meta.reviewOf) {
797
1270
  return this.enqueueJob({
798
1271
  groupId: message.groupId,
1272
+ conversationId,
799
1273
  membershipId: member.membershipId,
800
1274
  kind: 'revision',
801
1275
  triggerSeq: message.seq,
@@ -807,6 +1281,7 @@ export class Engine {
807
1281
  }
808
1282
  return this.enqueueJob({
809
1283
  groupId: message.groupId,
1284
+ conversationId,
810
1285
  membershipId: member.membershipId,
811
1286
  kind: 'chat',
812
1287
  triggerSeq: message.seq,
@@ -817,61 +1292,31 @@ export class Engine {
817
1292
  }
818
1293
 
819
1294
  // ── M2: multi-round discussion ──────────────────────────────────────────
820
- async startDiscussion({ groupId, topic, membershipIds, rounds, summarize }) {
821
- const group = this.repo.getGroup(groupId);
822
- if (!group) throw Object.assign(new Error('群不存在'), { code: 'not-found' });
823
- if (this.repo.activeDiscussionOf(groupId)) {
824
- throw Object.assign(new Error('该群已有进行中的讨论,请先停止或等待结束'), { code: 'conflict' });
825
- }
826
- const members = [];
827
- for (const membershipId of membershipIds) {
828
- const member = this.repo.getMember(membershipId);
829
- if (member && member.groupId === groupId && member.enabled !== false && this.repo.getBot(member.botId)) {
830
- members.push(member.membershipId);
831
- }
832
- }
833
- if (members.length === 0) throw Object.assign(new Error('请选择至少一个讨论成员'), { code: 'invalid-input' });
834
- this.assertQuota(groupId, members.length);
835
- const discussion = {
836
- discussionId: newId('dsc'),
837
- groupId,
838
- topic,
839
- memberIds: members,
840
- rounds,
841
- currentRound: 1,
842
- currentIndex: 0,
843
- currentSpeaker: members[0],
844
- summarize: summarize === true,
845
- summaryStarted: false,
846
- status: 'running',
847
- startSeq: 0,
848
- startMessageId: null,
849
- createdAt: now(),
850
- updatedAt: now(),
851
- };
852
- const startMessage = this.createMessage({
853
- groupId,
854
- author: { type: 'system', name: '系统' },
855
- text: `开始讨论:${topic}(${members.length} 位成员,共 ${rounds} 轮${summarize ? ',结束生成总结' : ''})`,
856
- kind: 'discussion',
857
- status: 'done',
858
- meta: { discussionId: discussion.discussionId, event: 'start' },
859
- });
860
- discussion.startSeq = startMessage.seq;
861
- discussion.startMessageId = startMessage.messageId;
862
- await this.repo.putDiscussion(discussion);
863
- this.bus.emit(groupId, { type: 'message.added', message: startMessage });
864
- this.bus.emit(groupId, { type: 'discussion.updated', discussion });
865
- this.scheduleDiscussion(discussion.discussionId);
866
- return 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);
867
1308
  }
868
1309
 
869
1310
  scheduleDiscussion(discussionId) {
870
1311
  if (this.disposed) return;
871
1312
  const discussion = this.repo.getDiscussion(discussionId);
872
- if (!discussion || discussion.status !== 'running') return;
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;
873
1318
  try {
874
- this.assertQuota(discussion.groupId, 1);
1319
+ this.assertQuota(discussion.groupId);
875
1320
  } catch (error) {
876
1321
  this.stopDiscussionInternal(discussion, 'quota', String(error && error.message ? error.message : error));
877
1322
  return;
@@ -884,6 +1329,7 @@ export class Engine {
884
1329
  this.bus.emit(discussion.groupId, { type: 'discussion.updated', discussion });
885
1330
  this.enqueueJob({
886
1331
  groupId: discussion.groupId,
1332
+ conversationId: conversationIdOf(this.repo, discussion),
887
1333
  membershipId: discussion.memberIds[0],
888
1334
  kind: 'summary',
889
1335
  discussionId,
@@ -922,6 +1368,7 @@ export class Engine {
922
1368
  this.bus.emit(discussion.groupId, { type: 'discussion.updated', discussion });
923
1369
  this.enqueueJob({
924
1370
  groupId: discussion.groupId,
1371
+ conversationId: conversationIdOf(this.repo, discussion),
925
1372
  membershipId,
926
1373
  kind: 'discussion',
927
1374
  discussionId,
@@ -935,23 +1382,30 @@ export class Engine {
935
1382
  }
936
1383
 
937
1384
  stopDiscussionInternal(discussion, reason, message) {
938
- if (!discussion || discussion.status !== 'running') return;
1385
+ if (!discussion || discussion.status !== 'running' || !this.isCurrentRecord(discussion)) return;
939
1386
  discussion.status = 'stopped';
940
1387
  discussion.stopReason = reason;
941
1388
  discussion.currentSpeaker = null;
942
1389
  this.repo.putDiscussion(discussion);
943
1390
  this.bus.emit(discussion.groupId, { type: 'discussion.updated', discussion });
944
1391
  this.systemMessage(discussion.groupId, '讨论「' + discussion.topic + '」已停止:' + message);
1392
+ this.queue = this.queue.filter((queued) => queued.discussionId !== discussion.discussionId);
945
1393
  for (const job of [...this.running.values()]) {
946
1394
  if (job.discussionId === discussion.discussionId && !job.finished) this.abortJob(job, 'user');
947
1395
  }
948
- this.queue = this.queue.filter((queued) => queued.discussionId !== discussion.discussionId);
949
1396
  this.settleDiscussionWaiters(discussion.discussionId);
950
1397
  }
951
1398
 
952
1399
  stopDiscussion(discussionId) {
953
1400
  const discussion = this.repo.getDiscussion(discussionId);
954
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
+ }
955
1409
  if (discussion.status !== 'running') return { stopped: false };
956
1410
  discussion.status = 'stopped';
957
1411
  discussion.stopReason = 'user';
@@ -959,9 +1413,6 @@ export class Engine {
959
1413
  this.repo.putDiscussion(discussion);
960
1414
  this.bus.emit(discussion.groupId, { type: 'discussion.updated', discussion });
961
1415
  this.systemMessage(discussion.groupId, `讨论「${discussion.topic}」已停止。`);
962
- for (const job of [...this.running.values()]) {
963
- if (job.discussionId === discussionId && !job.finished) this.abortJob(job, 'user');
964
- }
965
1416
  const keep = [];
966
1417
  for (const queued of this.queue) {
967
1418
  if (queued.discussionId === discussionId) {
@@ -975,6 +1426,9 @@ export class Engine {
975
1426
  }
976
1427
  }
977
1428
  this.queue = keep;
1429
+ for (const job of [...this.running.values()]) {
1430
+ if (job.discussionId === discussionId && !job.finished) this.abortJob(job, 'user');
1431
+ }
978
1432
  this.settleDiscussionWaiters(discussionId);
979
1433
  return { stopped: true };
980
1434
  }
@@ -982,6 +1436,8 @@ export class Engine {
982
1436
  discussionTranscript(discussion) {
983
1437
  return this.repo.messagesOf(discussion.groupId).filter(
984
1438
  (message) =>
1439
+ this.isCurrentRecord(message, discussion.groupId) &&
1440
+ conversationIdOf(this.repo, message) === conversationIdOf(this.repo, discussion) &&
985
1441
  message.meta &&
986
1442
  message.meta.discussionId === discussion.discussionId &&
987
1443
  message.author &&
@@ -1003,7 +1459,7 @@ export class Engine {
1003
1459
 
1004
1460
  whenDiscussionSettled(discussionId, timeoutMs = 600000) {
1005
1461
  const discussion = this.repo.getDiscussion(discussionId);
1006
- if (!discussion || discussion.status !== 'running') return Promise.resolve(true);
1462
+ if (!discussion || !isDiscussionActive(discussion) || discussion.status === 'paused') return Promise.resolve(true);
1007
1463
  return new Promise((resolve) => {
1008
1464
  const entry = { resolve, timer: setTimeout(() => resolve(false), timeoutMs) };
1009
1465
  const list = this.discussionWaiters.get(discussionId) ?? [];
@@ -1013,6 +1469,9 @@ export class Engine {
1013
1469
  }
1014
1470
 
1015
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));
1016
1475
  if (job.promptText) return job.promptText;
1017
1476
  if (job.kind === 'task') {
1018
1477
  const task = job.meta && job.meta.taskId ? this.repo.getTask(job.meta.taskId) : undefined;
@@ -1122,62 +1581,53 @@ export class Engine {
1122
1581
  }
1123
1582
 
1124
1583
  stopMember(membershipId) {
1125
- let stopped = 0;
1126
1584
  const job = this.running.get(membershipId);
1127
- if (job && !job.finished) {
1128
- this.abortJob(job, 'user');
1129
- stopped += 1;
1130
- }
1131
- const keep = [];
1132
- for (const queued of this.queue) {
1133
- if (queued.membershipId === membershipId) {
1134
- stopped += 1;
1135
- this.bus.emit(queued.groupId, {
1136
- type: 'member.status',
1137
- membershipId: queued.membershipId,
1138
- status: 'idle',
1139
- });
1140
- } else {
1141
- keep.push(queued);
1142
- }
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));
1143
1592
  }
1144
- this.queue = keep;
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); }
1145
1602
  return { stopped };
1146
1603
  }
1147
1604
 
1148
1605
  stopGroup(groupId) {
1606
+ this.pumpBlocks.add(groupId);
1149
1607
  let stopped = 0;
1150
- // Stop running discussions first so their jobs do not advance afterwards.
1608
+ // Close all discussion schedulers before clearing queues or cancelling jobs.
1151
1609
  for (const discussion of this.repo.discussionsOf(groupId)) {
1152
- if (discussion.status === 'running') {
1153
- discussion.status = 'stopped';
1154
- discussion.stopReason = 'user';
1155
- discussion.currentSpeaker = null;
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;
1156
1615
  this.repo.putDiscussion(discussion);
1157
1616
  this.bus.emit(groupId, { type: 'discussion.updated', discussion });
1158
1617
  this.settleDiscussionWaiters(discussion.discussionId);
1159
- this.systemMessage(groupId, `讨论「${discussion.topic}」已停止。`);
1160
1618
  }
1161
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
+ });
1162
1626
  for (const job of [...this.running.values()]) {
1163
1627
  if (job.groupId !== groupId || job.finished) continue;
1164
- this.abortJob(job, 'user');
1165
- stopped += 1;
1628
+ this.abortJob(job, 'user'); stopped += 1;
1166
1629
  }
1167
- const keep = [];
1168
- for (const job of this.queue) {
1169
- if (job.groupId === groupId) {
1170
- stopped += 1;
1171
- this.bus.emit(groupId, {
1172
- type: 'member.status',
1173
- membershipId: job.membershipId,
1174
- status: 'idle',
1175
- });
1176
- } else {
1177
- keep.push(job);
1178
- }
1179
- }
1180
- this.queue = keep;
1630
+ this.pumpBlocks.delete(groupId);
1181
1631
  this.checkIdle(groupId);
1182
1632
  return { stopped };
1183
1633
  }
@@ -1203,6 +1653,10 @@ export class Engine {
1203
1653
  });
1204
1654
  }
1205
1655
 
1656
+ hasPendingWork(groupId) {
1657
+ return [...this.pendingWork].some((operation) => operation.groupId === groupId);
1658
+ }
1659
+
1206
1660
  isRunning(groupId) {
1207
1661
  return this.runningCount(groupId) > 0 || this.queuedCount(groupId) > 0;
1208
1662
  }
@@ -1212,6 +1666,7 @@ export class Engine {
1212
1666
  try {
1213
1667
  this.offSession?.();
1214
1668
  this.offClaim?.();
1669
+ this.offGoal?.();
1215
1670
  } catch {
1216
1671
  /* noop */
1217
1672
  }