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/CHANGELOG.md +194 -0
- package/README.md +206 -156
- package/client.js +3706 -746
- package/cordis.patch.yml +2 -2
- package/host/agents.js +469 -289
- package/host/background-interactions.js +213 -0
- package/host/conversations.js +45 -0
- package/host/discussion-flow.js +179 -0
- package/host/discussion-runner.js +491 -0
- package/host/domain.js +551 -69
- package/host/engine.js +621 -166
- package/host/execution.js +192 -121
- package/host/native-activity.js +225 -0
- package/host/native-session.js +144 -0
- package/host/pdf.js +43 -6
- package/host/prompt.js +253 -9
- package/host/routes.js +31 -1
- package/host/service.js +751 -127
- package/host/session-export.js +22 -0
- package/host/session-visibility.js +147 -0
- package/host/storage-layout.js +109 -0
- package/host/tools.js +314 -85
- package/host/update-check.js +351 -0
- package/host/util.js +3 -2
- package/host/workspace.js +36 -2
- package/index.js +1 -1
- package/package.json +14 -6
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) =>
|
|
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
|
|
141
|
-
|
|
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(
|
|
151
|
-
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
543
|
+
const input = {
|
|
330
544
|
role: 'user',
|
|
331
545
|
id: inputMessageId,
|
|
332
546
|
content: [{ type: 'text', text: prompt }],
|
|
333
|
-
source:
|
|
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:
|
|
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
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
543
|
-
|
|
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
|
-
|
|
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.
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
const
|
|
1132
|
-
|
|
1133
|
-
if (
|
|
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
|
-
|
|
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
|
-
//
|
|
1608
|
+
// Close all discussion schedulers before clearing queues or cancelling jobs.
|
|
1151
1609
|
for (const discussion of this.repo.discussionsOf(groupId)) {
|
|
1152
|
-
if (discussion.
|
|
1153
|
-
|
|
1154
|
-
discussion.
|
|
1155
|
-
|
|
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
|
-
|
|
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
|
}
|