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.
@@ -0,0 +1,491 @@
1
+ /** Durable, serial step execution. Engine jobs may span several native turns. */
2
+ import { newId, now } from './util.js';
3
+ import { normalizeDiscussionInput, createDiscussionPlan, isDiscussionActive } from './discussion-flow.js';
4
+ import { buildDiscussionStepPrompt, parseDebateVerdict } from './prompt.js';
5
+ import { conversationIdOf } from './conversations.js';
6
+
7
+ const failure = (code, message) => Object.assign(new Error(message), { code });
8
+ const copy = value => structuredClone(value);
9
+ const finalPhases = new Set(['summary', 'closing', 'judgement']);
10
+ const terminal = record => record.status === 'done' || record.status === 'stopped';
11
+ const messageIds = job => [...new Set([...(job.previousReplies || []), job.placeholderId].filter(Boolean))];
12
+
13
+ export class DiscussionRunner {
14
+ constructor(engine) {
15
+ this.engine = engine;
16
+ this.repo = engine.repo;
17
+ this.chains = new Map();
18
+ this.groupChains = new Map();
19
+ this.closed = new Set();
20
+ }
21
+
22
+ serial(map, key, operation, groupId) {
23
+ const pending = { groupId };
24
+ this.engine.pendingWork.add(pending);
25
+ const previous = map.get(key);
26
+ let resolve, reject;
27
+ const result = new Promise((done, fail) => { resolve = done; reject = fail; });
28
+ const tail = result.catch(() => {});
29
+ map.set(key, tail);
30
+ if (previous) previous.then(operation).then(resolve, reject);
31
+ else {
32
+ try { Promise.resolve(operation()).then(resolve, reject); } catch (error) { reject(error); }
33
+ }
34
+ tail.then(() => {
35
+ this.engine.pendingWork.delete(pending);
36
+ if (map.get(key) === tail) map.delete(key);
37
+ this.engine.checkIdle(groupId);
38
+ });
39
+ return result;
40
+ }
41
+
42
+ lock(id, operation) {
43
+ return this.serial(this.chains, id, operation, this.repo.getDiscussion(id)?.groupId);
44
+ }
45
+
46
+ read(id) {
47
+ const record = this.repo.getDiscussion(id);
48
+ if (!record) throw failure('not-found', '讨论不存在');
49
+ return copy(record);
50
+ }
51
+
52
+ async save(record) {
53
+ record.revision = (record.revision || 0) + 1;
54
+ record.updatedAt = now();
55
+ await this.repo.putDiscussion(record);
56
+ if (this.engine.isCurrentRecord(record)) this.engine.bus.emit(record.groupId, { type: 'discussion.updated', discussion: copy(record) });
57
+ if (terminal(record) || record.status === 'paused') this.engine.settleDiscussionWaiters(record.discussionId);
58
+ return record;
59
+ }
60
+
61
+ liveJobs(id) {
62
+ return [...this.engine.running.values()].filter(job => job.discussionId === id && !job.finished);
63
+ }
64
+
65
+ queued(id) { return this.engine.queue.filter(job => job.discussionId === id); }
66
+
67
+ snapshotContext(groupId, ids = []) {
68
+ if (!Array.isArray(ids) || ids.some(id => typeof id !== 'string') || new Set(ids).size !== ids.length || ids.length > 20) {
69
+ throw failure('invalid-input', '引用消息列表无效(最多 20 条且不可重复)');
70
+ }
71
+ return ids.map(id => this.engine.captureQuote(groupId, id));
72
+ }
73
+
74
+ async ensureStartMessage(record) {
75
+ const existing = this.repo.getMessage(record.startMessageId);
76
+ if (existing && record.pauseReason !== 'start-error') return;
77
+ const message = existing || this.engine.createMessage({
78
+ groupId: record.groupId, conversationId: record.conversationId, author: { type: 'system', name: '系统' },
79
+ text: (record.mode === 'debate' ? '开始辩论:' : '开始讨论:') + record.topic,
80
+ kind: 'discussion', status: 'done', meta: { discussionId: record.discussionId, event: 'start', mode: record.mode },
81
+ });
82
+ await this.repo.putMessageStrict(message);
83
+ this.engine.admitConversation(record.groupId, record.conversationId);
84
+ record.startMessageId = message.messageId; record.startSeq = message.seq;
85
+ if (!existing) this.engine.bus.emit(record.groupId, { type: 'message.added', message });
86
+ }
87
+
88
+ async start(input) {
89
+ return this.serial(this.groupChains, input.groupId, async () => {
90
+ const e = this.engine;
91
+ const conversationId = e.admitConversation(input.groupId, input.expectedConversationId);
92
+ const existing = this.repo.discussionsOf(input.groupId);
93
+ if (input.requestId) {
94
+ const previous = existing.find(record => record.startRequestId === input.requestId && e.isCurrentRecord(record));
95
+ if (previous) return copy(previous);
96
+ }
97
+ if (existing.some(record => isDiscussionActive(record) && e.isCurrentRecord(record))) {
98
+ throw failure('conflict', '该群已有进行中的讨论,请先停止或等待结束');
99
+ }
100
+ const config = normalizeDiscussionInput(this.repo, input);
101
+ e.assertQuota(config.groupId);
102
+ const contextSnapshots = this.snapshotContext(config.groupId, config.contextMessageIds);
103
+ const steps = createDiscussionPlan(config);
104
+ const record = {
105
+ ...config, discussionId: newId('dsc'), conversationId, protocolVersion: 2,
106
+ participants: config.participants.map(participant => ({ ...participant,
107
+ sessionId: this.repo.getMember(participant.membershipId)?.sessionId || null })),
108
+ status: 'running', steps, currentStepId: null, currentSpeaker: null,
109
+ currentRound: 1, currentIndex: 0, phase: steps[0]?.phase || null, revision: 0,
110
+ contextSnapshots, supplements: [], controlRequests: [], startRequestId: input.requestId || null,
111
+ startSeq: 0, startMessageId: null, createdAt: now(), updatedAt: now(), partial: false,
112
+ };
113
+ const startMessage = e.createMessage({
114
+ groupId: config.groupId, conversationId, author: { type: 'system', name: '系统' },
115
+ text: (config.mode === 'debate' ? '开始辩论:' : '开始讨论:') + config.topic,
116
+ kind: 'discussion', status: 'done', meta: { discussionId: record.discussionId, event: 'start', mode: config.mode },
117
+ });
118
+ record.startSeq = startMessage.seq;
119
+ record.startMessageId = startMessage.messageId;
120
+ try {
121
+ await this.save(record);
122
+ e.admitConversation(record.groupId, conversationId);
123
+ await this.repo.putMessageStrict(startMessage);
124
+ e.admitConversation(record.groupId, conversationId);
125
+ } catch (error) {
126
+ if (e.isCurrentRecord(record)) {
127
+ record.status = 'paused'; record.pauseReason = 'start-error'; record.error = String(error?.message || error);
128
+ try { await this.save(record); } catch (saveError) { e.logger?.warn?.('bot-team: failed to persist start recovery: ' + String(saveError)); }
129
+ }
130
+ throw error;
131
+ }
132
+ e.bus.emit(record.groupId, { type: 'message.added', message: startMessage });
133
+ this.kick(record.discussionId);
134
+ return copy(record);
135
+ }, input.groupId);
136
+ }
137
+
138
+ kick(id) {
139
+ void this.schedule(id).catch(error => this.failed(id, error));
140
+ }
141
+
142
+ async failed(id, error) {
143
+ this.closed.add(id);
144
+ this.engine.logger?.warn?.('bot-team: discussion persistence/scheduling failed: ' + String(error));
145
+ try {
146
+ await this.lock(id, async () => {
147
+ const record = this.read(id);
148
+ if (terminal(record)) return;
149
+ record.status = 'paused';
150
+ record.pauseReason = 'execution-error';
151
+ record.error = String(error?.message || error);
152
+ await this.save(record);
153
+ });
154
+ } catch (saveError) {
155
+ this.engine.logger?.warn?.('bot-team: cannot persist paused discussion: ' + String(saveError));
156
+ this.engine.settleDiscussionWaiters(id);
157
+ }
158
+ }
159
+
160
+ pointer(record, step) {
161
+ record.currentStepId = step?.stepId || null;
162
+ record.currentSpeaker = step?.membershipId || null;
163
+ if (step) {
164
+ record.phase = step.phase;
165
+ record.currentRound = step.round;
166
+ record.currentIndex = record.steps.indexOf(step);
167
+ }
168
+ }
169
+
170
+ validateMember(record, step) {
171
+ const member = this.repo.getMember(step.membershipId);
172
+ const participant = record.participants.find(item => item.membershipId === step.membershipId);
173
+ if (!member || member.groupId !== record.groupId || member.enabled === false || !this.repo.getBot(member.botId) ||
174
+ (participant && participant.botId !== member.botId)) throw failure('member-unavailable', '讨论成员已删除、停用或身份已改变');
175
+ this.engine.assertQuota(record.groupId);
176
+ return member;
177
+ }
178
+
179
+ async schedule(id) {
180
+ return this.lock(id, async () => {
181
+ const e = this.engine;
182
+ const record = this.read(id);
183
+ if (e.disposed || this.closed.has(id) || !['running', 'finalizing'].includes(record.status) || !e.isCurrentRecord(record)) return;
184
+ if (this.liveJobs(id).length || this.queued(id).length) return;
185
+ // A persisted running claim cannot be assumed abandoned by a second pump.
186
+ if (record.steps.some(step => step.status === 'running')) return;
187
+ const step = record.steps.find(step => step.status === 'pending');
188
+ if (!step) {
189
+ record.status = 'done';
190
+ this.pointer(record, null);
191
+ await this.save(record);
192
+ return;
193
+ }
194
+ this.pointer(record, step);
195
+ const attempt = { attemptId: newId('attempt'), jobId: newId('job'), sessionId: null,
196
+ inputMessageId: null, messageIds: [], status: 'pending', createdAt: now() };
197
+ step.attempts.push(attempt);
198
+ step.status = 'running';
199
+ try { this.validateMember(record, step); }
200
+ catch (error) {
201
+ step.status = attempt.status = 'error';
202
+ step.error = attempt.error = String(error?.message || error);
203
+ record.status = 'paused'; record.pauseReason = error.code || 'member-error';
204
+ record.lastFailedStepId = step.stepId;
205
+ await this.save(record);
206
+ return;
207
+ }
208
+ await this.save(record); // durable claim precedes queue insertion, including the job ID
209
+ if (this.closed.has(id) || e.disposed) return;
210
+ const meta = { discussionId: id, stepId: step.stepId, attemptId: attempt.attemptId,
211
+ mode: record.mode, phase: step.phase, side: step.side ?? null, round: step.round,
212
+ summary: step.phase === 'summary' || step.phase === 'judgement', speaker: step.membershipId };
213
+ try {
214
+ e.enqueueJob({ jobId: attempt.jobId, groupId: record.groupId, conversationId: record.conversationId,
215
+ membershipId: step.membershipId, kind: 'discussion_step', messageKind: 'discussion',
216
+ discussionId: id, stepId: step.stepId, attemptId: attempt.attemptId, round: step.round,
217
+ triggerSeq: this.repo.lastMessageOf(record.groupId)?.seq ?? record.startSeq,
218
+ triggerMessageId: record.startMessageId, meta });
219
+ } catch (error) {
220
+ step.status = attempt.status = 'error'; step.error = attempt.error = String(error?.message || error);
221
+ record.status = 'paused'; record.pauseReason = 'dispatch-error'; record.lastFailedStepId = step.stepId;
222
+ await this.save(record);
223
+ }
224
+ });
225
+ }
226
+
227
+ match(record, job) {
228
+ const step = record.steps.find(item => item.stepId === job.stepId);
229
+ const attempt = step?.attempts.find(item => item.attemptId === job.attemptId && item.jobId === job.jobId);
230
+ return { step, attempt };
231
+ }
232
+
233
+ assertDispatch(job) {
234
+ const record = this.repo.getDiscussion(job.discussionId);
235
+ if (!record || !this.engine.isCurrentRecord(record) || this.closed.has(job.discussionId) ||
236
+ !['running', 'pausing', 'finalizing'].includes(record.status)) throw failure('discussion-paused', '讨论当前不能派发步骤');
237
+ const { step, attempt } = this.match(record, job);
238
+ if (!step || step.status !== 'running' || !attempt || step.attempts.at(-1) !== attempt) throw failure('stale-step', '讨论步骤已变化');
239
+ this.validateMember(record, step);
240
+ return { record, step, attempt };
241
+ }
242
+
243
+ prepare(job) {
244
+ return this.lock(job.discussionId, async () => {
245
+ this.assertDispatch(job);
246
+ const record = this.read(job.discussionId);
247
+ const { step, attempt } = this.match(record, job);
248
+ const member = this.validateMember(record, step);
249
+ const prompt = buildDiscussionStepPrompt({ repo: this.repo, botId: member.botId, groupId: record.groupId,
250
+ discussion: copy(record), step: copy(step), transcript: copy(this.engine.discussionTranscript(record)) });
251
+ attempt.prompt = prompt;
252
+ attempt.dispatchedAt = now();
253
+ attempt.status = 'running';
254
+ attempt.messageIds = messageIds(job);
255
+ step.messageIds = [...new Set([...step.messageIds, ...attempt.messageIds])];
256
+ await this.save(record);
257
+ job.promptText = prompt;
258
+ });
259
+ }
260
+
261
+ execution(job) {
262
+ return this.lock(job.discussionId, async () => {
263
+ const record = this.read(job.discussionId);
264
+ const { step, attempt } = this.match(record, job);
265
+ if (!attempt) return;
266
+ attempt.sessionId = job.execution.sessionId || attempt.sessionId;
267
+ attempt.inputMessageId = attempt.inputMessageId || job.execution.inputMessageId;
268
+ attempt.messageIds = [...new Set([...attempt.messageIds, ...messageIds(job)])];
269
+ step.messageIds = [...new Set([...step.messageIds, ...attempt.messageIds])];
270
+ await this.save(record);
271
+ });
272
+ }
273
+
274
+ async finished(job) {
275
+ await job.discussionMessageWrites;
276
+ await this.lock(job.discussionId, async () => {
277
+ const record = this.read(job.discussionId);
278
+ const { step, attempt } = this.match(record, job);
279
+ if (!attempt || step.attempts.at(-1) !== attempt || ['done', 'error', 'skipped'].includes(attempt.status)) return;
280
+ const ids = messageIds(job);
281
+ attempt.messageIds = [...new Set([...attempt.messageIds, ...ids])];
282
+ step.messageIds = [...new Set([...step.messageIds, ...attempt.messageIds])];
283
+ attempt.sessionId = job.sessionId || attempt.sessionId;
284
+ attempt.inputMessageId = attempt.inputMessageId || job.execution?.inputMessageId;
285
+ attempt.finishedAt = now();
286
+ const stopping = ['stopping', 'stopped'].includes(record.status) || this.closed.has(record.discussionId);
287
+ const ok = job.state === 'done' && !job.discussionPersistenceError;
288
+ attempt.status = step.status = ok ? 'done' : stopping ? 'skipped' : 'error';
289
+ if (!ok) {
290
+ attempt.error = step.error = job.discussionPersistenceError || job.error || '成员执行失败或中断';
291
+ if (!stopping) {
292
+ record.status = 'paused'; record.pauseReason = 'member-error'; record.lastFailedStepId = step.stepId;
293
+ }
294
+ }
295
+ if (ok && (step.phase === 'summary' || step.phase === 'judgement')) this.setSummary(record, step, attempt);
296
+ if (record.status === 'pausing') { record.status = 'paused'; record.pauseReason = 'user'; }
297
+ this.pointer(record, step);
298
+ await this.save(record);
299
+ });
300
+ if (this.repo.getDiscussion(job.discussionId)?.status === 'stopping') this.checkStopping(job.discussionId);
301
+ else this.kick(job.discussionId);
302
+ }
303
+
304
+ setSummary(record, step, attempt) {
305
+ const messages = attempt.messageIds.map(id => this.repo.getMessage(id)).filter(message => message?.status === 'done');
306
+ record.summary = messages.map(message => message.text || '').filter(Boolean).join('\n\n');
307
+ if (step.phase === 'judgement') {
308
+ const evidence = side => ['opening', 'closing'].every(phase => record.steps.some(item => item.side === side && item.phase === phase &&
309
+ item.status === 'done' && (item.attempts.at(-1)?.messageIds || []).some(id => {
310
+ const message = this.repo.getMessage(id);
311
+ return message?.status === 'done' && String(message.text || '').trim();
312
+ })));
313
+ record.verdict = evidence('pro') && evidence('con') ? (parseDebateVerdict(record.summary) || 'insufficient') : 'insufficient';
314
+ }
315
+ }
316
+
317
+ reconcile(record) {
318
+ const messages = this.repo.messagesOf(record.groupId).filter(message =>
319
+ conversationIdOf(this.repo, message) === conversationIdOf(this.repo, record) && message.meta?.discussionId === record.discussionId);
320
+ for (const step of record.steps) {
321
+ if (['done', 'skipped'].includes(step.status)) continue;
322
+ const attempt = step.attempts.at(-1);
323
+ if (!attempt) continue;
324
+ const matches = messages.filter(message => message.meta?.stepId === step.stepId && message.meta?.attemptId === attempt.attemptId &&
325
+ message.meta?.execution?.jobId === attempt.jobId);
326
+ attempt.messageIds = [...new Set([...attempt.messageIds, ...matches.map(message => message.messageId)])];
327
+ step.messageIds = [...new Set([...step.messageIds, ...attempt.messageIds])];
328
+ const last = matches.at(-1);
329
+ if (last?.status === 'done' && last.meta.logicalJobComplete === true) {
330
+ step.status = attempt.status = 'done';
331
+ attempt.sessionId = last.meta.execution.sessionId || attempt.sessionId;
332
+ attempt.inputMessageId ||= matches.find(message => message.meta.execution.inputMessageId)?.meta.execution.inputMessageId || null;
333
+ if (step.phase === 'summary' || step.phase === 'judgement') this.setSummary(record, step, attempt);
334
+ } else if (step.status === 'running') {
335
+ step.status = attempt.status = 'error';
336
+ step.error = attempt.error = '执行中断;请重试此步骤,或提前结束以收束讨论';
337
+ record.lastFailedStepId = step.stepId;
338
+ }
339
+ }
340
+ record.needsRecovery = false;
341
+ }
342
+
343
+ removeQueued(record, reason) {
344
+ const ids = new Set(this.queued(record.discussionId).map(job => job.jobId));
345
+ this.engine.queue = this.engine.queue.filter(job => !ids.has(job.jobId));
346
+ for (const step of record.steps) {
347
+ const attempt = step.attempts.at(-1);
348
+ if (attempt && ids.has(attempt.jobId)) {
349
+ attempt.status = 'skipped'; attempt.error = reason; attempt.finishedAt = now();
350
+ step.status = 'pending';
351
+ }
352
+ }
353
+ }
354
+
355
+ control(input) {
356
+ return this.lock(input.discussionId, async () => {
357
+ const e = this.engine;
358
+ const record = this.read(input.discussionId);
359
+ e.admitConversation(record.groupId, input.expectedConversationId);
360
+ e.assertCurrentTarget(record.groupId, record);
361
+ if (input.requestId && record.controlRequests?.some(request => request.requestId === input.requestId)) return copy(record);
362
+ if (input.expectedRevision !== undefined && input.expectedRevision !== record.revision) {
363
+ throw Object.assign(failure('stale-revision', '讨论状态已变化,请刷新后重试'), { details: { currentRevision: record.revision } });
364
+ }
365
+ const op = input.operation;
366
+ if (!['pause', 'resume', 'retry', 'finish', 'stop', 'add_context'].includes(op)) throw failure('invalid-input', '无效的讨论控制操作');
367
+ if (terminal(record)) {
368
+ if (op === 'stop') return record;
369
+ throw failure('conflict', '讨论已结束');
370
+ }
371
+ if (record.recoveryStatus === 'stopping' && op !== 'stop' && op !== 'add_context') {
372
+ throw failure('conflict', '此讨论已请求停止,请先完成停止');
373
+ }
374
+ const live = this.liveJobs(record.discussionId);
375
+ if ((record.needsRecovery || ['resume', 'retry'].includes(op) || (op === 'finish' && !live.length)) && !['add_context', 'stop'].includes(op)) {
376
+ await e.discussionGuard(record);
377
+ this.reconcile(record);
378
+ }
379
+ if (['resume', 'retry', 'finish'].includes(op)) await this.ensureStartMessage(record);
380
+ if (op === 'add_context') {
381
+ if (input.text !== undefined && (typeof input.text !== 'string' || input.text.length > 12000)) throw failure('invalid-input', '补充内容无效或超过 12000 字');
382
+ const text = (input.text || '').trim();
383
+ const contextSnapshots = this.snapshotContext(record.groupId, input.contextMessageIds || []);
384
+ if (!text && !contextSnapshots.length) throw failure('invalid-input', '请输入补充内容或选择引用消息');
385
+ record.supplements.push({ supplementId: newId('supplement'), text, contextMessageIds: input.contextMessageIds || [], contextSnapshots, createdAt: now() });
386
+ } else if (op === 'pause') {
387
+ this.removeQueued(record, '讨论已暂停');
388
+ if (['running', 'pausing', 'finalizing'].includes(record.status)) {
389
+ record.resumeStatus = record.status === 'finalizing' ? 'finalizing' : record.resumeStatus || 'running';
390
+ record.status = live.length ? 'pausing' : 'paused'; record.pauseReason = 'user';
391
+ }
392
+ } else if (op === 'resume' || op === 'retry') {
393
+ if (record.status !== 'paused') throw failure('conflict', '只有暂停的讨论可以继续或重试');
394
+ if (live.length) throw failure('conflict', '当前步骤仍在结束,请稍后继续');
395
+ this.reconcile(record);
396
+ if (op === 'retry') {
397
+ const step = record.steps.find(item => item.stepId === record.lastFailedStepId && item.status === 'error') || record.steps.find(item => item.status === 'error');
398
+ if (!step) throw failure('conflict', '没有失败的步骤可重试');
399
+ step.status = 'pending'; delete step.error;
400
+ } else if (record.steps.some(step => step.status === 'error')) {
401
+ await this.save(record);
402
+ throw failure('retry-required', '讨论有失败的步骤,请使用重试;提前结束或停止可以跳过未完成步骤');
403
+ }
404
+ record.status = record.resumeStatus === 'finalizing' || record.finishRequested ? 'finalizing' : 'running';
405
+ record.pauseReason = null; record.error = null;
406
+ this.closed.delete(record.discussionId);
407
+ } else if (op === 'finish') {
408
+ this.removeQueued(record, '提前结束');
409
+ record.partial = true; record.finishRequested = true; record.status = 'finalizing';
410
+ for (const step of record.steps) {
411
+ if (!['pending', 'error'].includes(step.status)) continue;
412
+ if (!finalPhases.has(step.phase)) step.status = 'skipped';
413
+ else if (step.status === 'error') { step.status = 'pending'; delete step.error; }
414
+ }
415
+ this.closed.delete(record.discussionId);
416
+ } else if (op === 'stop') {
417
+ this.closed.add(record.discussionId); // close dispatch before touching queue or cancelling an Agent
418
+ record.status = 'stopping'; record.stopReason = 'user';
419
+ this.removeQueued(record, '讨论已停止');
420
+ await this.save(record);
421
+ for (const job of live) e.abortJob(job, 'user');
422
+ // Native cancellation is asynchronous. Keep the active slot until a
423
+ // fresh guard confirms jobs, goals, inbox and descendants are quiescent.
424
+ for (const step of record.steps) if (['pending', 'running', 'error'].includes(step.status)) step.status = 'skipped';
425
+ await this.finishStop(record);
426
+ }
427
+ if (input.requestId) record.controlRequests = [...(record.controlRequests || []), { requestId: input.requestId, operation: op }];
428
+ await this.save(record);
429
+ if (['running', 'finalizing'].includes(record.status)) this.kick(record.discussionId);
430
+ e.checkIdle(record.groupId);
431
+ return copy(record);
432
+ });
433
+ }
434
+
435
+ /** Synchronous scheduler barrier used by the legacy group/member stop APIs. */
436
+ interrupt(id, { stop = false } = {}) {
437
+ this.closed.add(id);
438
+ return this.lock(id, async () => {
439
+ const record = this.read(id);
440
+ if (terminal(record)) return;
441
+ this.removeQueued(record, stop ? '讨论已停止' : '成员已停止');
442
+ for (const step of record.steps) if (step.status === 'running') {
443
+ step.status = stop ? 'skipped' : 'error';
444
+ const attempt = step.attempts.at(-1);
445
+ if (attempt) { attempt.status = step.status; attempt.error = stop ? '讨论已停止' : '成员已停止'; }
446
+ record.lastFailedStepId = step.stepId;
447
+ }
448
+ if (stop) for (const step of record.steps) if (step.status === 'pending') step.status = 'skipped';
449
+ record.status = stop ? 'stopping' : 'paused';
450
+ record.pauseReason = stop ? null : 'member-error';
451
+ record.stopReason = stop ? 'user' : null;
452
+ this.pointer(record, null);
453
+ if (stop) await this.finishStop(record);
454
+ await this.save(record);
455
+ });
456
+ }
457
+
458
+ async finishStop(record) {
459
+ try {
460
+ for (const step of record.steps) for (const attempt of step.attempts) {
461
+ this.engine.withdrawDiscussionInput({ groupId: record.groupId, conversationId: record.conversationId,
462
+ stepId: step.stepId, jobId: attempt.jobId, sessionId: attempt.sessionId, execution: { inputMessageId: attempt.inputMessageId } });
463
+ }
464
+ await this.engine.discussionGuard(record);
465
+ record.status = 'stopped'; record.needsRecovery = false; record.stopPendingReason = null;
466
+ this.pointer(record, null);
467
+ } catch (error) {
468
+ record.status = 'stopping'; record.stopPendingReason = String(error?.message || error);
469
+ }
470
+ }
471
+
472
+ checkStopping(id) {
473
+ void this.lock(id, async () => {
474
+ const record = this.read(id);
475
+ if (record.status !== 'stopping') return;
476
+ await this.finishStop(record);
477
+ await this.save(record);
478
+ }).catch(error => this.engine.logger?.warn?.('bot-team: stop completion check failed: ' + String(error)));
479
+ }
480
+
481
+ nativeSettled(sessionId) {
482
+ const member = this.engine.pool.memberForSession?.(sessionId);
483
+ if (!member) return;
484
+ for (const record of this.repo.discussionsOf(member.groupId)) {
485
+ if (record.protocolVersion === 2 && record.status === 'stopping' && this.engine.isCurrentRecord(record) &&
486
+ (record.memberIds.includes(member.membershipId) || record.steps.some(step => step.attempts.some(attempt => attempt.sessionId === sessionId)))) {
487
+ this.checkStopping(record.discussionId);
488
+ }
489
+ }
490
+ }
491
+ }