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/service.js
CHANGED
|
@@ -10,12 +10,20 @@ import { appendFile, mkdir, stat } from 'node:fs/promises';
|
|
|
10
10
|
import { createRequire } from 'node:module';
|
|
11
11
|
|
|
12
12
|
import { openRepository } from './domain.js';
|
|
13
|
+
import { currentConversationId, conversationIdOf, isCurrentMemberSession } from './conversations.js';
|
|
14
|
+
import { collectNativeConversationBlockers, nativeConversationCapability } from './native-activity.js';
|
|
15
|
+
import { openPluginStorageDomain } from './storage-layout.js';
|
|
13
16
|
import { CHAT_DISPLAY_SETTINGS_KEY, isChatDisplaySettings, normalizeChatDisplaySettings } from './chat-settings.js';
|
|
14
17
|
import { Personas } from './personas.js';
|
|
15
18
|
import { AgentPool } from './agents.js';
|
|
19
|
+
import { BackgroundInteractions } from './background-interactions.js';
|
|
20
|
+
import { BotSessionVisibility } from './session-visibility.js';
|
|
16
21
|
import { Engine } from './engine.js';
|
|
17
|
-
import {
|
|
22
|
+
import { isDiscussionActive, MAX_DISCUSSION_ROUNDS } from './discussion-flow.js';
|
|
23
|
+
import { readBotSessionHistory, readBotSessionLog, readMessageExecution } from './execution.js';
|
|
24
|
+
import { serializeBotSessionLog } from './session-export.js';
|
|
18
25
|
import { registerRoutes } from './routes.js';
|
|
26
|
+
import { UpdateChecker } from './update-check.js';
|
|
19
27
|
import { BOT_TEAM_TOOL_NAMES, registerBotTeamTools } from './tools.js';
|
|
20
28
|
import { GroupBus, asString, fail, json, newId, now, trimmed } from './util.js';
|
|
21
29
|
import {
|
|
@@ -34,12 +42,13 @@ import {
|
|
|
34
42
|
} from './layout.js';
|
|
35
43
|
import { parseMentionsFromText, renderGroupContext, renderPersona } from './prompt.js';
|
|
36
44
|
import { buildGroupPdf } from './pdf.js';
|
|
37
|
-
import { defaultWorkspaceDir, ensureWorkspaceDir, isStandardLayout, migrateLegacyLayout } from './workspace.js';
|
|
45
|
+
import { defaultWorkspaceDir, ensureWorkspaceDir, isStandardLayout, migrateLegacyLayout, normalizeWorkspacePath, sessionCwdMatches, validateProjectWorkspace } from './workspace.js';
|
|
38
46
|
|
|
39
47
|
class ServiceError extends Error {
|
|
40
|
-
constructor(code, message) {
|
|
48
|
+
constructor(code, message, details) {
|
|
41
49
|
super(message);
|
|
42
50
|
this.code = code;
|
|
51
|
+
if (details) this.details = details;
|
|
43
52
|
}
|
|
44
53
|
}
|
|
45
54
|
|
|
@@ -55,7 +64,7 @@ try {
|
|
|
55
64
|
export const PLUGIN_INFO = Object.freeze({
|
|
56
65
|
name: 'Bot Team',
|
|
57
66
|
packageName: typeof pluginPackage.name === 'string' ? pluginPackage.name : 'dsh-bot-team',
|
|
58
|
-
version: typeof pluginPackage.version === 'string' ? pluginPackage.version : '1.
|
|
67
|
+
version: typeof pluginPackage.version === 'string' ? pluginPackage.version : '1.3.0',
|
|
59
68
|
author: typeof pluginPackage.author === 'string'
|
|
60
69
|
? pluginPackage.author
|
|
61
70
|
: pluginPackage.author && typeof pluginPackage.author.name === 'string'
|
|
@@ -75,9 +84,15 @@ const ATTACHMENT_ID_RE = /^sha256:[0-9a-f]{64}$/;
|
|
|
75
84
|
|
|
76
85
|
const BOT_COLORS = ['#2f7ed8', '#8b5cf6', '#0d9488', '#d97706', '#db2777', '#4f46e5', '#65a30d', '#dc2626'];
|
|
77
86
|
|
|
78
|
-
// Avatar grammar: preset seeds are
|
|
87
|
+
// Avatar grammar: preset seeds are mascots; bundled Bot art may include a stable palette ID.
|
|
79
88
|
const AVATAR_PRESET_RE = /^preset:[a-z0-9-]{1,40}$/;
|
|
80
|
-
const AVATAR_ART_IDS = new Set([
|
|
89
|
+
const AVATAR_ART_IDS = new Set([
|
|
90
|
+
// Keep the original illustrations valid for saved Bot and group records.
|
|
91
|
+
'planet', 'rocket', 'cloud', 'bolt', 'leaf', 'flame', 'star', 'moon', 'wave', 'flower',
|
|
92
|
+
'bot-orbit', 'bot-spark', 'bot-circuit', 'bot-pixel', 'bot-scout', 'bot-assistant', 'bot-core', 'bot-android',
|
|
93
|
+
'group-team', 'group-bots', 'group-network', 'group-dialogue', 'group-lab', 'group-huddle', 'group-swarm', 'group-community',
|
|
94
|
+
]);
|
|
95
|
+
const AVATAR_ART_PALETTES = new Set(['sky', 'violet', 'mint', 'orange', 'pink', 'indigo', 'teal', 'slate']);
|
|
81
96
|
const MAX_AVATAR_CHARS = 800000;
|
|
82
97
|
|
|
83
98
|
function normalizeAvatar(value) {
|
|
@@ -90,9 +105,13 @@ function normalizeAvatar(value) {
|
|
|
90
105
|
return text;
|
|
91
106
|
}
|
|
92
107
|
if (text.startsWith('art:')) {
|
|
93
|
-
const
|
|
94
|
-
if (!AVATAR_ART_IDS.has(
|
|
95
|
-
|
|
108
|
+
const match = /^art:([a-z0-9-]+)(?::([a-z0-9-]+))?$/.exec(text);
|
|
109
|
+
if (!match || !AVATAR_ART_IDS.has(match[1])) throw new ServiceError('invalid-input', '未知的插画头像');
|
|
110
|
+
const [, id, paletteId] = match;
|
|
111
|
+
if (paletteId && (!id.startsWith('bot-') || !AVATAR_ART_PALETTES.has(paletteId))) {
|
|
112
|
+
throw new ServiceError('invalid-input', '未知的头像配色');
|
|
113
|
+
}
|
|
114
|
+
return text;
|
|
96
115
|
}
|
|
97
116
|
if (text.startsWith('data:image/')) {
|
|
98
117
|
if (text.length > MAX_AVATAR_CHARS) throw new ServiceError('invalid-input', '头像图片过大');
|
|
@@ -116,15 +135,19 @@ function optionalText(value, max) {
|
|
|
116
135
|
}
|
|
117
136
|
|
|
118
137
|
export class BotTeamService {
|
|
119
|
-
constructor(ctx, config) {
|
|
138
|
+
constructor(ctx, config, dependencies = {}) {
|
|
120
139
|
this.ctx = ctx;
|
|
121
140
|
this.config = config;
|
|
122
141
|
this.logger = ctx.logger;
|
|
123
142
|
this.bus = new GroupBus();
|
|
143
|
+
this.updateChecker = new UpdateChecker({ currentVersion: PLUGIN_INFO.version, ...dependencies.updateCheck });
|
|
124
144
|
this.repo = null;
|
|
125
145
|
this.personas = null;
|
|
126
146
|
this.pool = null;
|
|
127
147
|
this.engine = null;
|
|
148
|
+
this.conversationChanges = new Set();
|
|
149
|
+
this.workspaceChanges = new Set();
|
|
150
|
+
this.workspaceOperations = new Map();
|
|
128
151
|
this.modelCatalogCache = null;
|
|
129
152
|
this.smokeState = null;
|
|
130
153
|
this.routeDispose = registerRoutes(ctx, this);
|
|
@@ -132,13 +155,15 @@ export class BotTeamService {
|
|
|
132
155
|
this.disposed = false;
|
|
133
156
|
|
|
134
157
|
this.ready = this.init();
|
|
158
|
+
// Update detection is independent of chat startup and never delays ready.
|
|
159
|
+
void this.updateChecker.check();
|
|
135
160
|
this.ready.catch((error) => {
|
|
136
161
|
this.logger?.error?.(`bot-team: init failed: ${String(error && error.stack ? error.stack : error)}`);
|
|
137
162
|
});
|
|
138
163
|
}
|
|
139
164
|
|
|
140
165
|
async init() {
|
|
141
|
-
this.repo = await openRepository(this.ctx, this.logger);
|
|
166
|
+
this.repo = await openRepository(this.ctx, this.logger, spec => openPluginStorageDomain(this.ctx.storageDomain, spec, this.logger));
|
|
142
167
|
const migration = await this.repo.runMigrations();
|
|
143
168
|
if (migration.applied.length > 0) {
|
|
144
169
|
this.logger?.info?.('bot-team: migrated domain records to schema v' + migration.version);
|
|
@@ -152,6 +177,14 @@ export class BotTeamService {
|
|
|
152
177
|
)}`);
|
|
153
178
|
}
|
|
154
179
|
}
|
|
180
|
+
// Older group records already allowed custom workspaces; expose them as
|
|
181
|
+
// imported projects without changing the directory or its contents.
|
|
182
|
+
for (const group of this.repo.groups.values()) {
|
|
183
|
+
if (typeof group.projectWorkspaceCwd === 'string') continue;
|
|
184
|
+
group.projectWorkspaceCwd = group.workspaceCwd && !sessionCwdMatches(group.workspaceCwd, defaultWorkspaceDir(this.config, group))
|
|
185
|
+
? group.workspaceCwd : '';
|
|
186
|
+
await this.repo.putGroup(group);
|
|
187
|
+
}
|
|
155
188
|
this.personas = new Personas(this.config.botsDir, this.logger);
|
|
156
189
|
this.pool = new AgentPool({
|
|
157
190
|
ctx: this.ctx,
|
|
@@ -160,6 +193,7 @@ export class BotTeamService {
|
|
|
160
193
|
config: this.config,
|
|
161
194
|
logger: this.logger,
|
|
162
195
|
});
|
|
196
|
+
this.pool.assertAdmission = (groupId) => this.assertWorkspaceStable(groupId);
|
|
163
197
|
this.pool.attachPrompt({
|
|
164
198
|
renderPersona: (botId, groupId) => renderPersona(this.repo, this.personas, botId, groupId),
|
|
165
199
|
renderGroupContext: (botId, groupId) => renderGroupContext(this.repo, botId, groupId),
|
|
@@ -172,10 +206,17 @@ export class BotTeamService {
|
|
|
172
206
|
bus: this.bus,
|
|
173
207
|
config: this.config,
|
|
174
208
|
logger: this.logger,
|
|
175
|
-
quotaGuard: (groupId
|
|
176
|
-
|
|
209
|
+
quotaGuard: (groupId) => this.checkQuota(groupId),
|
|
210
|
+
discussionGuard: (discussion) => this.assertDiscussionNativeIdle(discussion),
|
|
211
|
+
workspaceGuard: (groupId) => this.assertWorkspaceStable(groupId),
|
|
212
|
+
taskHook: async (job, message, status) => {
|
|
213
|
+
await this.onTaskJobFinished(job, message, status);
|
|
214
|
+
await this.visibility?.reconcile();
|
|
215
|
+
},
|
|
177
216
|
});
|
|
217
|
+
this.engine.assertAdmission = (groupId) => this.assertWorkspaceStable(groupId);
|
|
178
218
|
this.pool.attachStreamHandler((payload) => this.engine.onAssistantStream(payload));
|
|
219
|
+
this.interactions = new BackgroundInteractions({ ctx: this.ctx, repo: this.repo, bus: this.bus });
|
|
179
220
|
this.toolDispose = registerBotTeamTools({
|
|
180
221
|
ctx: this.ctx,
|
|
181
222
|
service: this,
|
|
@@ -183,6 +224,13 @@ export class BotTeamService {
|
|
|
183
224
|
logger: this.logger,
|
|
184
225
|
});
|
|
185
226
|
await this.personas.preload(this.repo.allBots().map((bot) => bot.botId));
|
|
227
|
+
await this.pool.ready;
|
|
228
|
+
await this.engine.recoverNativeSessions();
|
|
229
|
+
this.visibility = new BotSessionVisibility({
|
|
230
|
+
ctx: this.ctx, repo: this.repo, logger: this.logger,
|
|
231
|
+
isBusy: sessionId => this.engine.activeBySession.has(sessionId),
|
|
232
|
+
});
|
|
233
|
+
await this.visibility.ready;
|
|
186
234
|
this.logger?.info?.(`bot-team: ready (domain bot_team, bots=${this.repo.bots.size}, groups=${this.repo.groups.size})`);
|
|
187
235
|
|
|
188
236
|
if (this.config.smokeTest) {
|
|
@@ -193,12 +241,175 @@ export class BotTeamService {
|
|
|
193
241
|
return this;
|
|
194
242
|
}
|
|
195
243
|
|
|
244
|
+
conversationState(groupId) {
|
|
245
|
+
const conversation = this.repo?.activeConversation?.(groupId) || null;
|
|
246
|
+
const group = this.repo?.getGroup?.(groupId);
|
|
247
|
+
return {
|
|
248
|
+
activeConversationId: conversation?.conversationId || group?.activeConversationId || null,
|
|
249
|
+
currentConversationId: conversation?.conversationId || group?.activeConversationId || null,
|
|
250
|
+
conversationVersion: conversation?.ordinal || group?.conversationVersion || 0,
|
|
251
|
+
currentConversation: conversation,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
conversationCapability() {
|
|
256
|
+
if (typeof this.repo?.commitConversation !== 'function') return { supported: false, reason: '会话存储尚未就绪' };
|
|
257
|
+
return nativeConversationCapability({ ctx: this.ctx, pool: this.pool });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
isCurrentRecord(record) {
|
|
261
|
+
return !!record && conversationIdOf(this.repo, record) === currentConversationId(this.repo, record.groupId);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
assertCurrentRecord(record) {
|
|
265
|
+
if (record && !this.isCurrentRecord(record)) {
|
|
266
|
+
throw new ServiceError('historical-target', '目标属于已结束的会话,请选取内容后在当前会话重新发起', this.conversationState(record.groupId));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
assertExpectedConversation(groupId, expected, required = false) {
|
|
271
|
+
const state = this.conversationState(groupId);
|
|
272
|
+
if (!state.currentConversationId) return;
|
|
273
|
+
if (expected === undefined || expected === null || expected === '') {
|
|
274
|
+
if (required) throw new ServiceError('reload-required', '聊天已支持独立会话,请刷新页面后重试', state);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (expected !== state.currentConversationId) throw new ServiceError('stale-conversation', '聊天已开始新会话,草稿已保留', state);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async conversationBlockers(groupId, { ownChange = false } = {}) {
|
|
281
|
+
const blockers = [];
|
|
282
|
+
const add = (code, message) => blockers.push({ code, kind: 'chat', groupId, message });
|
|
283
|
+
if (!ownChange && this.conversationChanges?.has(groupId)) add('conversation-changing', '正在新建会话');
|
|
284
|
+
if (this.workspaceChanges?.has(groupId)) add('workspace-changing', '正在切换项目目录');
|
|
285
|
+
if (this.workspaceOperations?.get(groupId) > 0) add('operation-pending', '聊天操作尚未完成');
|
|
286
|
+
if (this.engine?.isRunning?.(groupId)) add('execution-running', '仍有运行或排队的回复');
|
|
287
|
+
if (this.engine?.hasPendingWork?.(groupId)) add('execution-settling', '执行结果尚未保存完成');
|
|
288
|
+
if (this.repo?.activeDiscussionOf?.(groupId)) add('discussion-running', '讨论尚未结束');
|
|
289
|
+
if (this.pool?.isPreparingGroup?.(groupId)) add('agent-preparing', 'Bot 正在准备会话');
|
|
290
|
+
const capability = this.conversationCapability();
|
|
291
|
+
if (!capability.supported) add('native-capability-unavailable', capability.reason || '宿主无法验证后台工作状态');
|
|
292
|
+
else {
|
|
293
|
+
try {
|
|
294
|
+
blockers.push(...await collectNativeConversationBlockers({ ctx: this.ctx, repo: this.repo, pool: this.pool, groupId, interactions: this.interactions }));
|
|
295
|
+
} catch (error) {
|
|
296
|
+
add('native-state-unavailable', '无法确认后台工作状态:' + String(error?.message || error));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return blockers;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async newConversation(input) {
|
|
303
|
+
this.conversationRequests ??= new Map();
|
|
304
|
+
const key = JSON.stringify([input.groupId, input.requestId]);
|
|
305
|
+
const existing = this.conversationRequests.get(key);
|
|
306
|
+
if (existing) {
|
|
307
|
+
if (existing.expectedConversationId !== input.expectedConversationId) throw new ServiceError('conflict', 'requestId 已用于其他会话请求');
|
|
308
|
+
return { ...await existing.promise, created: false };
|
|
309
|
+
}
|
|
310
|
+
const promise = this.performNewConversation(input);
|
|
311
|
+
this.conversationRequests.set(key, { expectedConversationId: input.expectedConversationId, promise });
|
|
312
|
+
try { return await promise; }
|
|
313
|
+
finally { this.conversationRequests.delete(key); }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async performNewConversation(input) {
|
|
317
|
+
const groupId = String(input.groupId || '');
|
|
318
|
+
if (!this.repo.getGroup(groupId)) throw new ServiceError('not-found', '群不存在');
|
|
319
|
+
const requestId = String(input.requestId || '');
|
|
320
|
+
if (!/^[a-zA-Z0-9_-]{1,160}$/.test(requestId)) throw new ServiceError('invalid-input', '新建会话需要有效的 requestId');
|
|
321
|
+
const previous = this.repo.conversationsOf(groupId).find((item) => item.requestId === requestId);
|
|
322
|
+
if (previous) {
|
|
323
|
+
if (previous.previousConversationId !== input.expectedConversationId) throw new ServiceError('conflict', 'requestId 已用于其他会话请求');
|
|
324
|
+
return { created: false, conversation: previous, ...this.conversationState(groupId) };
|
|
325
|
+
}
|
|
326
|
+
this.assertExpectedConversation(groupId, input.expectedConversationId, true);
|
|
327
|
+
this.assertWorkspaceStable(groupId);
|
|
328
|
+
const capability = this.conversationCapability();
|
|
329
|
+
if (!capability.supported) throw new ServiceError('conversation-capability-unavailable', capability.reason || '宿主无法验证后台工作状态');
|
|
330
|
+
this.conversationChanges ??= new Set();
|
|
331
|
+
this.conversationChanges.add(groupId);
|
|
332
|
+
try {
|
|
333
|
+
const check = async () => {
|
|
334
|
+
this.assertExpectedConversation(groupId, input.expectedConversationId, true);
|
|
335
|
+
const blockers = await this.conversationBlockers(groupId, { ownChange: true });
|
|
336
|
+
if (blockers.length) throw new ServiceError('conversation-busy', blockers.map((item) => item.message).join(';'), { ...this.conversationState(groupId), blockers });
|
|
337
|
+
};
|
|
338
|
+
const flush = async () => {
|
|
339
|
+
try { await this.repo.flushGroupWrites(groupId); }
|
|
340
|
+
catch (error) { throw new ServiceError('storage-failed', '聊天更改尚未保存成功:' + String(error?.message || error)); }
|
|
341
|
+
};
|
|
342
|
+
await check();
|
|
343
|
+
await flush();
|
|
344
|
+
await check();
|
|
345
|
+
for (const member of this.repo.membersOf(groupId)) {
|
|
346
|
+
try { await this.pool.disposeMember(member.membershipId, { strict: true }); }
|
|
347
|
+
catch (error) { throw new ServiceError('session-release-failed', '旧会话释放失败:' + String(error?.message || error)); }
|
|
348
|
+
}
|
|
349
|
+
await check();
|
|
350
|
+
await flush();
|
|
351
|
+
let result;
|
|
352
|
+
try { result = await this.repo.commitConversation(groupId, { expectedConversationId: input.expectedConversationId, requestId }); }
|
|
353
|
+
catch (error) {
|
|
354
|
+
if (['stale-conversation', 'conflict', 'invalid-input', 'conversation-corrupt'].includes(error?.code)) throw error;
|
|
355
|
+
throw new ServiceError('storage-failed', '新会话保存失败:' + String(error?.message || error));
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
Promise.resolve(this.interactions?.reconcile?.()).catch((error) => this.logger?.warn?.('bot-team: conversation interaction refresh failed: ' + String(error)));
|
|
359
|
+
this.bus.emit(groupId, { type: 'conversation.started', groupId, ...result, ...this.conversationState(groupId) });
|
|
360
|
+
} catch (error) {
|
|
361
|
+
this.logger?.warn?.('bot-team: conversation committed; notification failed: ' + String(error));
|
|
362
|
+
}
|
|
363
|
+
return { ...result, ...this.conversationState(groupId) };
|
|
364
|
+
} finally {
|
|
365
|
+
this.conversationChanges.delete(groupId);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
messageConversationBoundaries(groupId, messages, { beforeSeq, afterSeq } = {}) {
|
|
370
|
+
const all = this.repo.conversationsOf?.(groupId) || [];
|
|
371
|
+
if (!messages.length) return all.filter((item) => (beforeSeq === undefined || item.afterSeq < beforeSeq) && (afterSeq === undefined || item.afterSeq >= afterSeq));
|
|
372
|
+
const first = messages[0].seq;
|
|
373
|
+
const last = messages.at(-1).seq;
|
|
374
|
+
const leading = [...all].reverse().find((item) => item.afterSeq < first);
|
|
375
|
+
return all.filter((item) => item.afterSeq === leading?.afterSeq || (item.afterSeq >= first && (beforeSeq === undefined || item.afterSeq <= last)));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
handleConversations(request) {
|
|
379
|
+
return this.withReady(() => {
|
|
380
|
+
const params = new URL(request.url).searchParams;
|
|
381
|
+
const groupId = params.get('groupId') || '';
|
|
382
|
+
if (!this.repo.getGroup(groupId)) return json(fail('not-found', '群不存在'), 404);
|
|
383
|
+
const requestedLimit = Number(params.get('limit'));
|
|
384
|
+
const limit = Number.isSafeInteger(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, 200) : 100;
|
|
385
|
+
const all = this.repo.conversationsOf(groupId);
|
|
386
|
+
const id = params.get('conversationId');
|
|
387
|
+
if (id) {
|
|
388
|
+
const conversation = all.find((item) => item.conversationId === id);
|
|
389
|
+
if (!conversation) return json(fail('not-found', '会话不存在'), 404);
|
|
390
|
+
const rawCursor = params.get('cursor');
|
|
391
|
+
const cursor = rawCursor === null ? 0 : Number(rawCursor);
|
|
392
|
+
if (!Number.isSafeInteger(cursor) || cursor < 0) return json(fail('invalid-input', '会话日志游标无效'), 400);
|
|
393
|
+
const bindings = this.repo.sessionBindingsOf(groupId, id);
|
|
394
|
+
const sessionBindings = bindings.slice(cursor, cursor + limit);
|
|
395
|
+
const hasMore = cursor + sessionBindings.length < bindings.length;
|
|
396
|
+
return json({ ok: true, groupId, conversation, sessionBindings, hasMore, nextCursor: hasMore ? cursor + sessionBindings.length : null, ...this.conversationState(groupId) });
|
|
397
|
+
}
|
|
398
|
+
const before = params.has('beforeOrdinal') ? Number(params.get('beforeOrdinal')) : Infinity;
|
|
399
|
+
if (before !== Infinity && (!Number.isSafeInteger(before) || before < 1)) return json(fail('invalid-input', '会话分页游标无效'), 400);
|
|
400
|
+
const filtered = all.filter((item) => item.ordinal < before);
|
|
401
|
+
const conversations = filtered.slice(-limit);
|
|
402
|
+
return json({ ok: true, groupId, conversations, hasMore: filtered.length > conversations.length, nextBeforeOrdinal: conversations[0]?.ordinal ?? null, ...this.conversationState(groupId) });
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
196
406
|
// ── HTTP handlers ────────────────────────────────────────────────────────
|
|
197
|
-
async withReady(operation) {
|
|
407
|
+
async withReady(operation, signal) {
|
|
198
408
|
try {
|
|
199
409
|
await this.ready;
|
|
200
410
|
return await operation();
|
|
201
411
|
} catch (error) {
|
|
412
|
+
if (signal?.aborted) throw signal.reason ?? error;
|
|
202
413
|
const code = error instanceof ServiceError ? error.code : 'not-ready';
|
|
203
414
|
const status = code === 'not-ready' ? 503 : 400;
|
|
204
415
|
return json(fail(code, String(error && error.message ? error.message : error)), status);
|
|
@@ -213,6 +424,13 @@ export class BotTeamService {
|
|
|
213
424
|
return this.withReady(async () => json(await this.bootstrapPayload()));
|
|
214
425
|
}
|
|
215
426
|
|
|
427
|
+
async handleUpdateHttp(request) {
|
|
428
|
+
const updateInfo = request.method === 'POST'
|
|
429
|
+
? await this.updateChecker.check({ force: true })
|
|
430
|
+
: this.updateChecker.snapshot();
|
|
431
|
+
return json({ ok: true, updateInfo });
|
|
432
|
+
}
|
|
433
|
+
|
|
216
434
|
async bootstrapPayload() {
|
|
217
435
|
const usageByGroup = new Map();
|
|
218
436
|
for (const group of this.repo.groups.values()) {
|
|
@@ -226,14 +444,18 @@ export class BotTeamService {
|
|
|
226
444
|
}
|
|
227
445
|
usageByGroup.set(group.groupId, { inputTokens: input, outputTokens: output, totalTokens: input + output });
|
|
228
446
|
}
|
|
229
|
-
const groups = this.repo.allGroups().map((group) => {
|
|
447
|
+
const groups = await Promise.all(this.repo.allGroups().map(async (group) => {
|
|
448
|
+
const blockers = await this.conversationBlockers(group.groupId);
|
|
230
449
|
const members = this.repo.membersOf(group.groupId);
|
|
231
450
|
const lastMessage = this.repo.lastMessageOf(group.groupId);
|
|
232
|
-
const discussions = this.repo.discussionsOf(group.groupId);
|
|
233
|
-
const activeDiscussion = discussions.find(
|
|
451
|
+
const discussions = this.repo.discussionsOf(group.groupId).filter((item) => this.isCurrentRecord(item));
|
|
452
|
+
const activeDiscussion = discussions.find(isDiscussionActive) || null;
|
|
234
453
|
const latestDiscussion = discussions[0] || null;
|
|
235
454
|
return {
|
|
236
455
|
...group,
|
|
456
|
+
...this.conversationState(group.groupId),
|
|
457
|
+
canStartConversation: blockers.length === 0,
|
|
458
|
+
conversationBlockers: blockers,
|
|
237
459
|
memberIds: members.map((member) => member.membershipId),
|
|
238
460
|
running: this.engine.isRunning(group.groupId),
|
|
239
461
|
usage: usageByGroup.get(group.groupId) || { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
|
@@ -253,9 +475,10 @@ export class BotTeamService {
|
|
|
253
475
|
}
|
|
254
476
|
: null,
|
|
255
477
|
};
|
|
256
|
-
});
|
|
478
|
+
}));
|
|
257
479
|
return {
|
|
258
480
|
ok: true,
|
|
481
|
+
capabilities: { conversations: this.conversationCapability().supported, expectedConversationId: true },
|
|
259
482
|
bots: this.repo.allBots(),
|
|
260
483
|
groups,
|
|
261
484
|
folders: this.repo.allFolders(),
|
|
@@ -263,6 +486,7 @@ export class BotTeamService {
|
|
|
263
486
|
directGroupCount: [...this.repo.groups.values()].filter(isDirectGroup).length,
|
|
264
487
|
members: [...this.repo.members.values()].map((member) => ({
|
|
265
488
|
...member,
|
|
489
|
+
currentSessionId: isCurrentMemberSession(this.repo, member) ? member.sessionId : null,
|
|
266
490
|
running: this.engine.running.has(member.membershipId),
|
|
267
491
|
})),
|
|
268
492
|
config: {
|
|
@@ -273,7 +497,6 @@ export class BotTeamService {
|
|
|
273
497
|
defaultCwd: this.config.defaultCwd,
|
|
274
498
|
botSandbox: this.config.botSandbox,
|
|
275
499
|
botApprovalPolicy: this.config.botApprovalPolicy,
|
|
276
|
-
maxTurnsPerHour: this.config.maxTurnsPerHour,
|
|
277
500
|
dailyTokenBudget: this.config.dailyTokenBudget,
|
|
278
501
|
schemaVersion: this.repo.schemaVersion(),
|
|
279
502
|
},
|
|
@@ -282,6 +505,7 @@ export class BotTeamService {
|
|
|
282
505
|
modelCatalog: await this.getModelCatalog(),
|
|
283
506
|
// A preference save may complete while model catalog discovery awaits I/O.
|
|
284
507
|
chatDisplaySettings: this.getChatDisplaySettings(),
|
|
508
|
+
updateInfo: this.updateChecker?.snapshot(),
|
|
285
509
|
smoke: this.smokeState,
|
|
286
510
|
};
|
|
287
511
|
}
|
|
@@ -310,6 +534,8 @@ export class BotTeamService {
|
|
|
310
534
|
groupId,
|
|
311
535
|
messages,
|
|
312
536
|
hasMore,
|
|
537
|
+
...this.conversationState(groupId),
|
|
538
|
+
conversationBoundaries: this.messageConversationBoundaries(groupId, messages, { beforeSeq: beforeRaw, afterSeq: afterRaw }),
|
|
313
539
|
running: this.engine.isRunning(groupId),
|
|
314
540
|
});
|
|
315
541
|
});
|
|
@@ -329,6 +555,8 @@ export class BotTeamService {
|
|
|
329
555
|
members: this.repo.membersOf(groupId),
|
|
330
556
|
bots: this.repo.allBots(),
|
|
331
557
|
messages: this.repo.messagesOf(groupId),
|
|
558
|
+
conversations: this.repo.conversationsOf?.(groupId) || [],
|
|
559
|
+
truncated: false,
|
|
332
560
|
lang,
|
|
333
561
|
fontPath: this.config.pdfFontPath || '',
|
|
334
562
|
});
|
|
@@ -378,6 +606,63 @@ export class BotTeamService {
|
|
|
378
606
|
});
|
|
379
607
|
}
|
|
380
608
|
|
|
609
|
+
/** Read a group-owned Bot Session without activating it through ordinary Session routing. */
|
|
610
|
+
handleSessionHistory(request) {
|
|
611
|
+
return this.withReady(async () => {
|
|
612
|
+
const url = new URL(request.url);
|
|
613
|
+
let response;
|
|
614
|
+
try {
|
|
615
|
+
response = json(await readBotSessionHistory({
|
|
616
|
+
ctx: this.ctx,
|
|
617
|
+
repo: this.repo,
|
|
618
|
+
groupId: url.searchParams.get('groupId') || '',
|
|
619
|
+
sessionId: url.searchParams.get('sessionId') || '',
|
|
620
|
+
afterSeq: url.searchParams.get('afterSeq'),
|
|
621
|
+
limit: url.searchParams.get('limit'),
|
|
622
|
+
signal: request.signal,
|
|
623
|
+
}));
|
|
624
|
+
} catch (error) {
|
|
625
|
+
if (request.signal.aborted) throw error;
|
|
626
|
+
const code = ['not-found', 'invalid-input'].includes(error?.code) ? error.code : 'session-history-failed';
|
|
627
|
+
const status = code === 'not-found' ? 404 : code === 'invalid-input' ? 400 : 500;
|
|
628
|
+
response = json(fail(code, code === 'session-history-failed' ? 'Session history read failed' : String(error?.message || error)), status);
|
|
629
|
+
}
|
|
630
|
+
response.headers.set('cache-control', 'no-store');
|
|
631
|
+
return response;
|
|
632
|
+
}, request.signal);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Export the canonical persisted log without activating the Bot session. */
|
|
636
|
+
handleSessionExport(request) {
|
|
637
|
+
return this.withReady(async () => {
|
|
638
|
+
const url = new URL(request.url);
|
|
639
|
+
try {
|
|
640
|
+
const result = await readBotSessionLog({
|
|
641
|
+
ctx: this.ctx,
|
|
642
|
+
repo: this.repo,
|
|
643
|
+
groupId: url.searchParams.get('groupId') || '',
|
|
644
|
+
sessionId: url.searchParams.get('sessionId') || '',
|
|
645
|
+
signal: request.signal,
|
|
646
|
+
});
|
|
647
|
+
if (!result.ok || result.availability !== 'available') return json(result, 503);
|
|
648
|
+
const filename = 'session-' + result.sessionId.replace(/[^a-zA-Z0-9._-]/g, '_') + '.jsonl';
|
|
649
|
+
return new Response(serializeBotSessionLog(result.header, result.events), {
|
|
650
|
+
headers: {
|
|
651
|
+
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
652
|
+
'content-disposition': 'attachment; filename="' + filename + '"',
|
|
653
|
+
'cache-control': 'no-store',
|
|
654
|
+
'x-content-type-options': 'nosniff',
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
} catch (error) {
|
|
658
|
+
if (request.signal.aborted) throw error;
|
|
659
|
+
const code = ['not-found', 'invalid-input'].includes(error?.code) ? error.code : 'session-export-failed';
|
|
660
|
+
return json(fail(code, code === 'session-export-failed' ? 'Session log export failed' : String(error?.message || error)),
|
|
661
|
+
code === 'not-found' ? 404 : code === 'invalid-input' ? 400 : 500);
|
|
662
|
+
}
|
|
663
|
+
}, request.signal);
|
|
664
|
+
}
|
|
665
|
+
|
|
381
666
|
handleEvents(request) {
|
|
382
667
|
const url = new URL(request.url);
|
|
383
668
|
const groupId = url.searchParams.get('groupId') || '';
|
|
@@ -439,6 +724,7 @@ export class BotTeamService {
|
|
|
439
724
|
at: now(),
|
|
440
725
|
running: service.engine.isRunning(groupId),
|
|
441
726
|
chatDisplaySettings: service.getChatDisplaySettings(),
|
|
727
|
+
...service.conversationState(groupId),
|
|
442
728
|
});
|
|
443
729
|
// Push current member statuses so a reconnecting page is consistent.
|
|
444
730
|
for (const member of service.repo.membersOf(groupId)) {
|
|
@@ -502,7 +788,7 @@ export class BotTeamService {
|
|
|
502
788
|
}
|
|
503
789
|
return this.withReady(async () => {
|
|
504
790
|
try {
|
|
505
|
-
const result = await this.dispatch(body && body.action, body && body.payload);
|
|
791
|
+
const result = await this.dispatch(body && body.action, body && body.payload, { external: true });
|
|
506
792
|
return json({ ok: true, result });
|
|
507
793
|
} catch (error) {
|
|
508
794
|
const code = error instanceof ServiceError
|
|
@@ -510,7 +796,13 @@ export class BotTeamService {
|
|
|
510
796
|
: error && typeof error.code === 'string'
|
|
511
797
|
? error.code
|
|
512
798
|
: 'internal';
|
|
513
|
-
|
|
799
|
+
const status = ['stale-conversation', 'conversation-busy', 'historical-target', 'reload-required', 'conflict'].includes(code) ? 409
|
|
800
|
+
: code === 'not-found' && body?.action === 'conversation.new' ? 404
|
|
801
|
+
: code === 'conversation-capability-unavailable' ? 503
|
|
802
|
+
: ['internal', 'storage-failed', 'session-release-failed', 'conversation-corrupt'].includes(code) ? 500 : 400;
|
|
803
|
+
const conversationError = ['stale-conversation', 'conversation-busy', 'historical-target', 'reload-required', 'storage-failed', 'session-release-failed', 'conversation-corrupt'].includes(code);
|
|
804
|
+
const details = error?.details || (conversationError && body?.payload?.groupId ? this.conversationState(body.payload.groupId) : undefined);
|
|
805
|
+
return json({ ...fail(code, String(error && error.message ? error.message : error)), ...(details ? { details } : {}) }, status);
|
|
514
806
|
}
|
|
515
807
|
});
|
|
516
808
|
}
|
|
@@ -606,6 +898,10 @@ export class BotTeamService {
|
|
|
606
898
|
}
|
|
607
899
|
|
|
608
900
|
async listSkills(groupId, memberId = '') {
|
|
901
|
+
return this.withWorkspaceOperation(groupId, () => this.listSkillsInWorkspace(groupId, memberId));
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
async listSkillsInWorkspace(groupId, memberId) {
|
|
609
905
|
const group = this.repo.getGroup(groupId);
|
|
610
906
|
if (!group) throw new ServiceError('not-found', `群 ${groupId} 不存在`);
|
|
611
907
|
const members = this.repo.membersOf(groupId).filter(
|
|
@@ -704,16 +1000,57 @@ export class BotTeamService {
|
|
|
704
1000
|
}
|
|
705
1001
|
|
|
706
1002
|
// ── action dispatch ──────────────────────────────────────────────────────
|
|
707
|
-
|
|
1003
|
+
actionGroupIds(action, input) {
|
|
1004
|
+
const ids = new Set();
|
|
1005
|
+
const add = (value) => { if (value && this.repo.getGroup(value)) ids.add(value); };
|
|
1006
|
+
add(input.groupId);
|
|
1007
|
+
add(this.repo.getMember?.(input.membershipId)?.groupId);
|
|
1008
|
+
add(this.repo.getTask?.(input.taskId)?.groupId);
|
|
1009
|
+
add(this.repo.getDiscussion?.(input.discussionId)?.groupId);
|
|
1010
|
+
for (const key of ['messageId', 'targetMessageId', 'reviewMessageId']) add(this.repo.messages?.get(input[key])?.groupId);
|
|
1011
|
+
if (input.botId && ['bot.update', 'bot.delete', 'persona.write', 'direct.open'].includes(action)) {
|
|
1012
|
+
for (const member of this.repo.membersOfBot(input.botId)) add(member.groupId);
|
|
1013
|
+
}
|
|
1014
|
+
if (['group.reorder', 'folder.delete'].includes(action)) for (const group of this.repo.allGroups()) add(group.groupId);
|
|
1015
|
+
return [...ids].sort();
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
async dispatch(action, payload = {}, { external = false } = {}) {
|
|
708
1019
|
await this.ready;
|
|
709
|
-
const input = payload && typeof payload === 'object' ? payload : {};
|
|
1020
|
+
const input = payload && typeof payload === 'object' && !Array.isArray(payload) ? { ...payload } : {};
|
|
1021
|
+
if (action === 'conversation.new') return this.newConversation(input);
|
|
1022
|
+
const groupIds = this.actionGroupIds(action, input);
|
|
1023
|
+
const executing = ['message.post', 'message.retry', 'message.stop', 'review.request', 'review.revise',
|
|
1024
|
+
'discussion.start', 'discussion.stop', 'discussion.control', 'interaction.respond', 'task.create', 'task.dispatch',
|
|
1025
|
+
'task.dispatch_ready', 'task.review', 'task.accept', 'task.request_changes', 'task.revise', 'task.update', 'task.cancel'].includes(action);
|
|
1026
|
+
if (executing) {
|
|
1027
|
+
for (const groupId of groupIds) this.assertExpectedConversation(groupId, input.expectedConversationId, external);
|
|
1028
|
+
if (input.expectedConversationId === undefined && groupIds.length === 1) input.expectedConversationId = currentConversationId(this.repo, groupIds[0]);
|
|
1029
|
+
}
|
|
1030
|
+
// Workspace changes provide their own exclusive admission; counting that
|
|
1031
|
+
// same request as an ordinary operation would make it block itself.
|
|
1032
|
+
if (action === 'group.workspace.set' || (action === 'group.update' && input.workspaceCwd !== undefined)
|
|
1033
|
+
|| ['interaction.list', 'ui.report'].includes(action)) return this.dispatchAction(action, input);
|
|
1034
|
+
const perform = (index) => index < groupIds.length
|
|
1035
|
+
? this.withWorkspaceOperation(groupIds[index], () => perform(index + 1))
|
|
1036
|
+
: this.dispatchAction(action, input);
|
|
1037
|
+
return perform(0);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
async dispatchAction(action, input) {
|
|
710
1041
|
switch (action) {
|
|
1042
|
+
case 'interaction.list':
|
|
1043
|
+
return this.interactions.snapshot(input.groupId);
|
|
1044
|
+
case 'interaction.respond':
|
|
1045
|
+
return this.interactions.respond(input);
|
|
711
1046
|
case 'settings.chatDisplay.update':
|
|
712
1047
|
return this.updateChatDisplaySettings(input);
|
|
713
1048
|
case 'group.create':
|
|
714
1049
|
return this.createGroup(input);
|
|
715
1050
|
case 'group.update':
|
|
716
1051
|
return this.updateGroup(input);
|
|
1052
|
+
case 'group.workspace.set':
|
|
1053
|
+
return this.setGroupWorkspace(input);
|
|
717
1054
|
case 'group.delete':
|
|
718
1055
|
return this.deleteGroup(input);
|
|
719
1056
|
case 'group.reorder':
|
|
@@ -742,6 +1079,8 @@ export class BotTeamService {
|
|
|
742
1079
|
return this.updateMember(input);
|
|
743
1080
|
case 'message.post':
|
|
744
1081
|
return this.postMessage(input);
|
|
1082
|
+
case 'message.delete':
|
|
1083
|
+
return this.deleteMessage(input);
|
|
745
1084
|
case 'message.stop':
|
|
746
1085
|
return this.stopGroup(input);
|
|
747
1086
|
case 'message.retry':
|
|
@@ -753,7 +1092,11 @@ export class BotTeamService {
|
|
|
753
1092
|
case 'discussion.start':
|
|
754
1093
|
return this.startDiscussion(input);
|
|
755
1094
|
case 'discussion.stop':
|
|
756
|
-
return this.
|
|
1095
|
+
return this.stopDiscussion(input);
|
|
1096
|
+
case 'discussion.control':
|
|
1097
|
+
return this.controlDiscussion(input);
|
|
1098
|
+
case 'discussion.get':
|
|
1099
|
+
return this.getDiscussion(input);
|
|
757
1100
|
case 'task.create':
|
|
758
1101
|
return this.createTask(input);
|
|
759
1102
|
case 'task.dispatch':
|
|
@@ -799,6 +1142,7 @@ export class BotTeamService {
|
|
|
799
1142
|
}
|
|
800
1143
|
|
|
801
1144
|
isTaskReady(task) {
|
|
1145
|
+
if (!this.isCurrentRecord(task)) return { ready: false, blockers: [{ taskId: task.taskId, title: task.title, status: 'historical' }] };
|
|
802
1146
|
const blockers = [];
|
|
803
1147
|
for (const dependencyId of task.dependsOn || []) {
|
|
804
1148
|
const dependency = this.repo.getTask(dependencyId);
|
|
@@ -825,7 +1169,9 @@ export class BotTeamService {
|
|
|
825
1169
|
|
|
826
1170
|
listTasks(groupId, options = {}) {
|
|
827
1171
|
const includeClosed = options.includeClosed === true;
|
|
828
|
-
let tasks = this.repo.tasksOf(groupId)
|
|
1172
|
+
let tasks = this.repo.tasksOf(groupId)
|
|
1173
|
+
.filter((task) => options.includeHistorical === true || this.isCurrentRecord(task))
|
|
1174
|
+
.map((task) => this.taskView(task));
|
|
829
1175
|
if (options.taskId) tasks = tasks.filter((task) => task.taskId === options.taskId);
|
|
830
1176
|
if (options.status) tasks = tasks.filter((task) => task.status === options.status);
|
|
831
1177
|
if (!includeClosed && !options.status) {
|
|
@@ -847,6 +1193,7 @@ export class BotTeamService {
|
|
|
847
1193
|
if (taskId && dependencyId === taskId) throw new ServiceError('invalid-input', '任务不能依赖自身');
|
|
848
1194
|
const dependency = all.get(dependencyId);
|
|
849
1195
|
if (!dependency) throw new ServiceError('invalid-input', '依赖任务不存在:' + dependencyId);
|
|
1196
|
+
this.assertCurrentRecord(dependency);
|
|
850
1197
|
if (dependency.status === 'cancelled') throw new ServiceError('invalid-input', '依赖任务已取消:' + dependency.title);
|
|
851
1198
|
}
|
|
852
1199
|
if (taskId && ids.length > 0) {
|
|
@@ -875,10 +1222,12 @@ export class BotTeamService {
|
|
|
875
1222
|
|
|
876
1223
|
/** After a task is accepted, notify and mark dependents that became dispatchable. */
|
|
877
1224
|
async unblockDependents(task) {
|
|
1225
|
+
if (!this.isCurrentRecord(task)) return [];
|
|
878
1226
|
const dependents = this.repo
|
|
879
1227
|
.tasksOf(task.groupId)
|
|
880
1228
|
.filter(
|
|
881
1229
|
(candidate) =>
|
|
1230
|
+
this.isCurrentRecord(candidate) &&
|
|
882
1231
|
candidate.taskId !== task.taskId &&
|
|
883
1232
|
(candidate.dependsOn || []).includes(task.taskId) &&
|
|
884
1233
|
candidate.status !== 'accepted' &&
|
|
@@ -906,9 +1255,9 @@ export class BotTeamService {
|
|
|
906
1255
|
return views;
|
|
907
1256
|
}
|
|
908
1257
|
|
|
909
|
-
requireTaskQuota(groupId
|
|
1258
|
+
requireTaskQuota(groupId) {
|
|
910
1259
|
try {
|
|
911
|
-
this.engine.assertQuota(groupId
|
|
1260
|
+
this.engine.assertQuota(groupId);
|
|
912
1261
|
} catch (error) {
|
|
913
1262
|
if (error && error.code) throw new ServiceError(error.code, String(error.message));
|
|
914
1263
|
throw error;
|
|
@@ -941,6 +1290,7 @@ export class BotTeamService {
|
|
|
941
1290
|
const task = {
|
|
942
1291
|
taskId: newId('task'),
|
|
943
1292
|
groupId,
|
|
1293
|
+
conversationId: currentConversationId(this.repo, groupId),
|
|
944
1294
|
title,
|
|
945
1295
|
description,
|
|
946
1296
|
acceptance,
|
|
@@ -988,6 +1338,8 @@ export class BotTeamService {
|
|
|
988
1338
|
async dispatchTask(input) {
|
|
989
1339
|
const task = this.repo.getTask(String(input.taskId || ''));
|
|
990
1340
|
if (!task) throw new ServiceError('not-found', '任务不存在');
|
|
1341
|
+
this.assertCurrentRecord(task);
|
|
1342
|
+
this.assertExpectedConversation(task.groupId, input.expectedConversationId);
|
|
991
1343
|
if (task.status !== 'pending') throw new ServiceError('invalid-input', '只有待派发的任务可以派发(当前:' + task.status + ')');
|
|
992
1344
|
const readiness = this.isTaskReady(task);
|
|
993
1345
|
if (!readiness.ready) {
|
|
@@ -996,23 +1348,27 @@ export class BotTeamService {
|
|
|
996
1348
|
'任务被依赖阻塞:' + readiness.blockers.map((blocker) => blocker.title + '(' + blocker.status + ')').join('、'),
|
|
997
1349
|
);
|
|
998
1350
|
}
|
|
999
|
-
this.requireTaskQuota(task.groupId
|
|
1000
|
-
task.
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1351
|
+
this.requireTaskQuota(task.groupId);
|
|
1352
|
+
return this.withWorkspaceOperation(task.groupId, async () => {
|
|
1353
|
+
task.status = 'in_progress';
|
|
1354
|
+
await this.repo.putTask(task);
|
|
1355
|
+
this.emitTask(task);
|
|
1356
|
+
this.engine.enqueueTaskTurn({ taskId: task.taskId, phase: 'dispatch' });
|
|
1357
|
+
return task;
|
|
1358
|
+
});
|
|
1005
1359
|
}
|
|
1006
1360
|
|
|
1007
1361
|
async reviewTask(input) {
|
|
1008
1362
|
const task = this.repo.getTask(String(input.taskId || ''));
|
|
1009
1363
|
if (!task) throw new ServiceError('not-found', '任务不存在');
|
|
1364
|
+
this.assertCurrentRecord(task);
|
|
1365
|
+
this.assertExpectedConversation(task.groupId, input.expectedConversationId);
|
|
1010
1366
|
if (task.status !== 'delivered') throw new ServiceError('invalid-input', '只有已交付的任务可以评审(当前:' + task.status + ')');
|
|
1011
1367
|
const reviewerMembershipId = String(input.reviewerMembershipId || task.reviewerMembershipId || '');
|
|
1012
1368
|
const reviewer = this.repo.getMember(reviewerMembershipId);
|
|
1013
1369
|
if (!reviewer || reviewer.groupId !== task.groupId) throw new ServiceError('invalid-input', '评审者不在该群里');
|
|
1014
1370
|
if (!task.deliverableMessageId) throw new ServiceError('invalid-input', '任务还没有交付物');
|
|
1015
|
-
this.requireTaskQuota(task.groupId
|
|
1371
|
+
this.requireTaskQuota(task.groupId);
|
|
1016
1372
|
const instructions =
|
|
1017
1373
|
optionalText(input.instructions, 2000) ||
|
|
1018
1374
|
'请按验收标准评审该任务的交付物:第一行输出 结论:通过 或 结论:需修改,然后给理由与具体修改建议。' +
|
|
@@ -1035,20 +1391,26 @@ export class BotTeamService {
|
|
|
1035
1391
|
async acceptTask(input) {
|
|
1036
1392
|
const task = this.repo.getTask(String(input.taskId || ''));
|
|
1037
1393
|
if (!task) throw new ServiceError('not-found', '任务不存在');
|
|
1394
|
+
this.assertCurrentRecord(task);
|
|
1395
|
+
this.assertExpectedConversation(task.groupId, input.expectedConversationId);
|
|
1038
1396
|
if (task.status !== 'delivered' && task.status !== 'changes_requested') {
|
|
1039
1397
|
throw new ServiceError('invalid-input', '当前状态不能验收(' + task.status + ')');
|
|
1040
1398
|
}
|
|
1041
|
-
task.
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1399
|
+
return this.withWorkspaceOperation(task.groupId, async () => {
|
|
1400
|
+
task.status = 'accepted';
|
|
1401
|
+
task.acceptedAt = now();
|
|
1402
|
+
await this.repo.putTask(task);
|
|
1403
|
+
this.emitTask(task);
|
|
1404
|
+
await this.unblockDependents(task);
|
|
1405
|
+
return this.taskView(task);
|
|
1406
|
+
});
|
|
1047
1407
|
}
|
|
1048
1408
|
|
|
1049
1409
|
async updateTask(input) {
|
|
1050
1410
|
const task = this.repo.getTask(String(input.taskId || ''));
|
|
1051
1411
|
if (!task) throw new ServiceError('not-found', '任务不存在');
|
|
1412
|
+
this.assertCurrentRecord(task);
|
|
1413
|
+
this.assertExpectedConversation(task.groupId, input.expectedConversationId);
|
|
1052
1414
|
if (task.status === 'accepted' || task.status === 'cancelled') {
|
|
1053
1415
|
throw new ServiceError('invalid-input', '已验收/已取消的任务不能编辑');
|
|
1054
1416
|
}
|
|
@@ -1083,6 +1445,8 @@ export class BotTeamService {
|
|
|
1083
1445
|
async requestTaskChanges(input) {
|
|
1084
1446
|
const task = this.repo.getTask(String(input.taskId || ''));
|
|
1085
1447
|
if (!task) throw new ServiceError('not-found', '任务不存在');
|
|
1448
|
+
this.assertCurrentRecord(task);
|
|
1449
|
+
this.assertExpectedConversation(task.groupId, input.expectedConversationId);
|
|
1086
1450
|
if (task.status !== 'delivered') throw new ServiceError('invalid-input', '只有已交付的任务可以要求修改(当前:' + task.status + ')');
|
|
1087
1451
|
task.status = 'changes_requested';
|
|
1088
1452
|
task.changesRequestedAt = now();
|
|
@@ -1094,6 +1458,8 @@ export class BotTeamService {
|
|
|
1094
1458
|
async reviseTask(input) {
|
|
1095
1459
|
const task = this.repo.getTask(String(input.taskId || ''));
|
|
1096
1460
|
if (!task) throw new ServiceError('not-found', '任务不存在');
|
|
1461
|
+
this.assertCurrentRecord(task);
|
|
1462
|
+
this.assertExpectedConversation(task.groupId, input.expectedConversationId);
|
|
1097
1463
|
if (task.status !== 'changes_requested') throw new ServiceError('invalid-input', '只有被要求修改的任务可以重新交付(当前:' + task.status + ')');
|
|
1098
1464
|
const readiness = this.isTaskReady(task);
|
|
1099
1465
|
if (!readiness.ready) {
|
|
@@ -1102,22 +1468,27 @@ export class BotTeamService {
|
|
|
1102
1468
|
'任务被依赖阻塞:' + readiness.blockers.map((blocker) => blocker.title + '(' + blocker.status + ')').join('、'),
|
|
1103
1469
|
);
|
|
1104
1470
|
}
|
|
1105
|
-
this.requireTaskQuota(task.groupId
|
|
1106
|
-
task.
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1471
|
+
this.requireTaskQuota(task.groupId);
|
|
1472
|
+
return this.withWorkspaceOperation(task.groupId, async () => {
|
|
1473
|
+
task.status = 'in_progress';
|
|
1474
|
+
await this.repo.putTask(task);
|
|
1475
|
+
this.emitTask(task);
|
|
1476
|
+
this.engine.enqueueTaskTurn({ taskId: task.taskId, phase: 'revision' });
|
|
1477
|
+
return task;
|
|
1478
|
+
});
|
|
1111
1479
|
}
|
|
1112
1480
|
|
|
1113
1481
|
async cancelTask(input) {
|
|
1114
1482
|
const task = this.repo.getTask(String(input.taskId || ''));
|
|
1115
1483
|
if (!task) throw new ServiceError('not-found', '任务不存在');
|
|
1484
|
+
this.assertCurrentRecord(task);
|
|
1485
|
+
this.assertExpectedConversation(task.groupId, input.expectedConversationId);
|
|
1116
1486
|
if (task.status === 'accepted') throw new ServiceError('invalid-input', '已验收的任务不能取消');
|
|
1117
1487
|
const dependents = this.repo
|
|
1118
1488
|
.tasksOf(task.groupId)
|
|
1119
1489
|
.filter(
|
|
1120
1490
|
(candidate) =>
|
|
1491
|
+
this.isCurrentRecord(candidate) &&
|
|
1121
1492
|
candidate.taskId !== task.taskId &&
|
|
1122
1493
|
(candidate.dependsOn || []).includes(task.taskId) &&
|
|
1123
1494
|
candidate.status !== 'accepted' &&
|
|
@@ -1153,7 +1524,7 @@ export class BotTeamService {
|
|
|
1153
1524
|
const taskId = job && job.meta ? job.meta.taskId : undefined;
|
|
1154
1525
|
if (!taskId) return;
|
|
1155
1526
|
const task = this.repo.getTask(taskId);
|
|
1156
|
-
if (!task) return;
|
|
1527
|
+
if (!task || !this.isCurrentRecord(task) || (job.conversationId && job.conversationId !== currentConversationId(this.repo, task.groupId))) return;
|
|
1157
1528
|
if (job.kind === 'task' || job.kind === 'task_revision') {
|
|
1158
1529
|
if (status === 'done' && message) {
|
|
1159
1530
|
task.status = 'delivered';
|
|
@@ -1172,6 +1543,7 @@ export class BotTeamService {
|
|
|
1172
1543
|
task.revision = (task.revision || 0) + 1;
|
|
1173
1544
|
}
|
|
1174
1545
|
await this.repo.putTask(task);
|
|
1546
|
+
if (!this.isCurrentRecord(task)) return;
|
|
1175
1547
|
this.emitTask(task);
|
|
1176
1548
|
if (task.status === 'accepted') await this.unblockDependents(task);
|
|
1177
1549
|
} catch (hookError) {
|
|
@@ -1188,7 +1560,8 @@ export class BotTeamService {
|
|
|
1188
1560
|
const reviewerMembershipId = String(input.reviewerMembershipId || '');
|
|
1189
1561
|
const instructions = optionalText(input.instructions, 2000);
|
|
1190
1562
|
try {
|
|
1191
|
-
|
|
1563
|
+
this.assertExpectedConversation(groupId, input.expectedConversationId);
|
|
1564
|
+
return this.engine.requestReview({ groupId, targetMessageId, reviewerMembershipId, instructions, expectedConversationId: input.expectedConversationId });
|
|
1192
1565
|
} catch (error) {
|
|
1193
1566
|
if (error && error.code) throw new ServiceError(error.code, String(error.message));
|
|
1194
1567
|
throw error;
|
|
@@ -1202,6 +1575,7 @@ export class BotTeamService {
|
|
|
1202
1575
|
return this.engine.requestRevision({
|
|
1203
1576
|
groupId: String(input.groupId || ''),
|
|
1204
1577
|
reviewMessageId: String(input.reviewMessageId || ''),
|
|
1578
|
+
expectedConversationId: input.expectedConversationId,
|
|
1205
1579
|
});
|
|
1206
1580
|
} catch (error) {
|
|
1207
1581
|
if (error && error.code) throw new ServiceError(error.code, String(error.message));
|
|
@@ -1214,26 +1588,128 @@ export class BotTeamService {
|
|
|
1214
1588
|
const group = this.repo.getGroup(groupId);
|
|
1215
1589
|
if (!group) throw new ServiceError('not-found', '群不存在');
|
|
1216
1590
|
if (isDirectGroup(group)) throw new ServiceError('invalid-input', '单聊不支持多轮讨论,请使用群聊');
|
|
1591
|
+
this.assertExpectedConversation(groupId, input.expectedConversationId);
|
|
1592
|
+
const modern = input.mode === 'debate' || input.format === 'structured';
|
|
1593
|
+
if (modern && (typeof input.topic !== 'string' || input.topic.length > 2000)) {
|
|
1594
|
+
throw new ServiceError('invalid-input', '讨论议题必须是字符串且不能超过 2000 字');
|
|
1595
|
+
}
|
|
1217
1596
|
const topic = requireText(input.topic, '讨论议题', 2000);
|
|
1218
1597
|
const roundsRaw = Number(input.rounds);
|
|
1219
|
-
const rounds = Number.isInteger(roundsRaw) ? Math.min(Math.max(roundsRaw, 1),
|
|
1220
|
-
const
|
|
1221
|
-
|
|
1222
|
-
|
|
1598
|
+
const rounds = modern ? input.rounds : (Number.isInteger(roundsRaw) ? Math.min(Math.max(roundsRaw, 1), MAX_DISCUSSION_ROUNDS) : 2);
|
|
1599
|
+
const fields = ['mode', 'format', 'goal', 'background', 'summaryMembershipId', 'proMembershipId',
|
|
1600
|
+
'conMembershipId', 'judgeMembershipId', 'proPosition', 'conPosition', 'responseLength', 'language',
|
|
1601
|
+
'perspectives', 'contextMessageIds', 'requestId'];
|
|
1602
|
+
const config = Object.fromEntries(fields.filter(key => input[key] !== undefined).map(key => [key, input[key]]));
|
|
1223
1603
|
try {
|
|
1224
1604
|
return await this.engine.startDiscussion({
|
|
1225
|
-
groupId,
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
summarize: input.summarize === true,
|
|
1605
|
+
...config, groupId, topic, rounds,
|
|
1606
|
+
...(input.membershipIds !== undefined ? { membershipIds: input.membershipIds } : {}),
|
|
1607
|
+
...(input.summarize !== undefined ? { summarize: input.summarize } : modern ? {} : { summarize: false }),
|
|
1608
|
+
expectedConversationId: input.expectedConversationId,
|
|
1230
1609
|
});
|
|
1231
1610
|
} catch (error) {
|
|
1232
|
-
if (error
|
|
1611
|
+
if (error?.code) throw new ServiceError(error.code, String(error.message), error.details);
|
|
1612
|
+
throw error;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
requireDiscussion(input) {
|
|
1617
|
+
const discussion = this.repo.getDiscussion(String(input.discussionId || ''));
|
|
1618
|
+
if (!discussion || (input.groupId !== undefined && input.groupId !== discussion.groupId)) {
|
|
1619
|
+
throw new ServiceError('not-found', '讨论不存在或不属于当前群');
|
|
1620
|
+
}
|
|
1621
|
+
return discussion;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
/** Read a single run, including older conversations, without changing its state. */
|
|
1625
|
+
getDiscussion(input) {
|
|
1626
|
+
const discussion = this.requireDiscussion(input);
|
|
1627
|
+
const conversationId = conversationIdOf(this.repo, discussion);
|
|
1628
|
+
const transcript = this.repo.messagesOf(discussion.groupId).filter(message =>
|
|
1629
|
+
message.groupId === discussion.groupId && conversationIdOf(this.repo, message) === conversationId
|
|
1630
|
+
&& message.meta?.discussionId === discussion.discussionId);
|
|
1631
|
+
const finalMessage = [...transcript].reverse().find(message => {
|
|
1632
|
+
if (message.status !== 'done' || message.meta?.summary !== true) return false;
|
|
1633
|
+
if (discussion.protocolVersion < 2 || !message.meta.stepId) return true;
|
|
1634
|
+
const step = discussion.steps?.find(item => item.stepId === message.meta.stepId);
|
|
1635
|
+
return step?.status === 'done' && (!step.messageIds?.length || step.messageIds.includes(message.messageId));
|
|
1636
|
+
});
|
|
1637
|
+
const finalStep = [...(discussion.steps || [])].reverse().find(step => step.status === 'done' && ['summary', 'judgement'].includes(step.phase));
|
|
1638
|
+
// The runner assembles all native turns of the accepted logical attempt.
|
|
1639
|
+
// Keep its result readable even when an original message was later deleted.
|
|
1640
|
+
const summary = finalStep && typeof discussion.summary === 'string' ? discussion.summary : finalMessage?.text;
|
|
1641
|
+
const available = new Set(transcript.map(message => message.messageId));
|
|
1642
|
+
const missingMessageIds = [...new Set((discussion.steps || []).flatMap(step => step.messageIds || []))]
|
|
1643
|
+
.filter(id => !available.has(id));
|
|
1644
|
+
return structuredClone({ discussion, transcript,
|
|
1645
|
+
...(summary !== undefined ? { summary: String(summary || '') } : {}),
|
|
1646
|
+
...(['pro', 'con', 'tie', 'insufficient'].includes(discussion.verdict) ? { verdict: discussion.verdict } : {}),
|
|
1647
|
+
missingMessageIds,
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
async controlDiscussion(input) {
|
|
1652
|
+
const discussion = this.requireDiscussion(input);
|
|
1653
|
+
this.assertCurrentRecord(discussion);
|
|
1654
|
+
this.assertExpectedConversation(discussion.groupId, input.expectedConversationId);
|
|
1655
|
+
this.assertWorkspaceStable(discussion.groupId);
|
|
1656
|
+
if (input.expectedRevision !== undefined && (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0)) {
|
|
1657
|
+
throw new ServiceError('invalid-input', '讨论版本无效,请刷新后重试');
|
|
1658
|
+
}
|
|
1659
|
+
const operation = String(input.operation || '');
|
|
1660
|
+
if (!['pause', 'resume', 'retry', 'finish', 'stop', 'add_context'].includes(operation)) {
|
|
1661
|
+
throw new ServiceError('invalid-input', '不支持的讨论操作');
|
|
1662
|
+
}
|
|
1663
|
+
if (input.requestId !== undefined && (typeof input.requestId !== 'string' || !input.requestId.trim() || input.requestId.length > 160)) {
|
|
1664
|
+
throw new ServiceError('invalid-input', '讨论请求标识无效');
|
|
1665
|
+
}
|
|
1666
|
+
try {
|
|
1667
|
+
return await this.engine.controlDiscussion({
|
|
1668
|
+
discussionId: discussion.discussionId, groupId: discussion.groupId, operation,
|
|
1669
|
+
expectedConversationId: input.expectedConversationId, expectedRevision: input.expectedRevision,
|
|
1670
|
+
requestId: input.requestId, text: input.text, contextMessageIds: input.contextMessageIds,
|
|
1671
|
+
});
|
|
1672
|
+
} catch (error) {
|
|
1673
|
+
if (error?.code) throw new ServiceError(error.code, String(error.message), error.details);
|
|
1233
1674
|
throw error;
|
|
1234
1675
|
}
|
|
1235
1676
|
}
|
|
1236
1677
|
|
|
1678
|
+
async stopDiscussion(input) {
|
|
1679
|
+
const discussion = this.repo.getDiscussion(String(input.discussionId || ''));
|
|
1680
|
+
if (!discussion) return { stopped: false };
|
|
1681
|
+
if (discussion.protocolVersion >= 2) {
|
|
1682
|
+
const current = await this.controlDiscussion({ ...input, operation: 'stop' });
|
|
1683
|
+
return { stopped: current.status === 'stopped', discussion: current };
|
|
1684
|
+
}
|
|
1685
|
+
this.requireDiscussion(input);
|
|
1686
|
+
this.assertCurrentRecord(discussion);
|
|
1687
|
+
return this.engine.stopDiscussion(discussion.discussionId);
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
/** Inspect only participating member sessions; do not cancel unrelated work. */
|
|
1691
|
+
async assertDiscussionNativeIdle(discussion) {
|
|
1692
|
+
const membershipIds = new Set(discussion.memberIds || []);
|
|
1693
|
+
const sessionIds = new Set((discussion.steps || []).flatMap(step => (step.attempts || []).map(attempt => attempt.sessionId)).filter(Boolean));
|
|
1694
|
+
const members = new Map([...this.repo.members].filter(([id, member]) => member.groupId === discussion.groupId && membershipIds.has(id)));
|
|
1695
|
+
for (const member of members.values()) if (member.sessionId) sessionIds.add(member.sessionId);
|
|
1696
|
+
const scopedRepo = Object.create(this.repo);
|
|
1697
|
+
scopedRepo.members = members;
|
|
1698
|
+
scopedRepo.sessionBindings = new Map([...(this.repo.sessionBindings || [])].filter(([, binding]) =>
|
|
1699
|
+
binding.groupId === discussion.groupId && (membershipIds.has(binding.membershipId) || sessionIds.has(binding.sessionId))));
|
|
1700
|
+
const scopedPool = Object.create(this.pool || null);
|
|
1701
|
+
scopedPool.owned = new Map([...(this.pool?.owned || [])].filter(([agent, ownership]) => membershipIds.has(ownership.membershipId) || sessionIds.has(agent.id)));
|
|
1702
|
+
scopedPool.isPreparingGroup = () => [...(this.pool?.preparing?.keys() || []), ...(this.pool?.submitting?.keys() || []),
|
|
1703
|
+
...[...(this.pool?.overlayPreparing?.values() || [])].map(value => value.membershipId)].some(id => membershipIds.has(id));
|
|
1704
|
+
const scopedInteractions = {
|
|
1705
|
+
entries: new Map([...(this.interactions?.entries || [])].filter(([, entry]) => membershipIds.has(entry.view?.membershipId) || sessionIds.has(entry.view?.sessionId))),
|
|
1706
|
+
responding: new Set([...(this.interactions?.responding || [])].filter(entry => membershipIds.has(entry.view?.membershipId) || sessionIds.has(entry.view?.sessionId))),
|
|
1707
|
+
};
|
|
1708
|
+
const blockers = (await collectNativeConversationBlockers({ ctx: this.ctx, repo: scopedRepo, pool: scopedPool,
|
|
1709
|
+
groupId: discussion.groupId, interactions: scopedInteractions })).filter(blocker => !blocker.membershipId || membershipIds.has(blocker.membershipId));
|
|
1710
|
+
if (blockers.length) throw new ServiceError('native-busy', '讨论成员的原生活动尚未结束,暂不能重新派发', { blockers });
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1237
1713
|
/**
|
|
1238
1714
|
* Client-side UI verification breadcrumbs (page rendered, bootstrap/messages
|
|
1239
1715
|
* loaded, SSE hello). Recorded into the same smoke log so M1 verification has
|
|
@@ -1282,26 +1758,100 @@ export class BotTeamService {
|
|
|
1282
1758
|
return folder;
|
|
1283
1759
|
}
|
|
1284
1760
|
|
|
1761
|
+
assertWorkspaceStable(groupId) {
|
|
1762
|
+
if (this.conversationChanges?.has(groupId)) {
|
|
1763
|
+
throw new ServiceError('conversation-busy', '正在新建会话,请稍后重试', this.conversationState(groupId));
|
|
1764
|
+
}
|
|
1765
|
+
if (this.workspaceChanges?.has(groupId)) {
|
|
1766
|
+
throw new ServiceError('busy', '正在切换项目目录,请稍后重试');
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/** Reserve a group before an async operation can prepare an agent or enqueue work. */
|
|
1771
|
+
async withWorkspaceOperation(groupId, operation) {
|
|
1772
|
+
this.assertWorkspaceStable(groupId);
|
|
1773
|
+
this.workspaceOperations ??= new Map();
|
|
1774
|
+
this.workspaceOperations.set(groupId, (this.workspaceOperations.get(groupId) || 0) + 1);
|
|
1775
|
+
try {
|
|
1776
|
+
return await operation();
|
|
1777
|
+
} finally {
|
|
1778
|
+
const remaining = this.workspaceOperations.get(groupId) - 1;
|
|
1779
|
+
if (remaining > 0) this.workspaceOperations.set(groupId, remaining);
|
|
1780
|
+
else this.workspaceOperations.delete(groupId);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
workspaceBusy(groupId) {
|
|
1785
|
+
return this.engine.isRunning(groupId)
|
|
1786
|
+
|| this.engine.hasPendingWork?.(groupId)
|
|
1787
|
+
|| this.repo.activeDiscussionOf(groupId)
|
|
1788
|
+
|| this.workspaceOperations?.get(groupId) > 0
|
|
1789
|
+
|| this.pool.isPreparingGroup?.(groupId);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/** Hold admission closed from before the first filesystem await through persistence. */
|
|
1793
|
+
async withWorkspaceChange(groupId, value, commit) {
|
|
1794
|
+
const group = this.repo.getGroup(groupId);
|
|
1795
|
+
if (!group) throw new ServiceError('not-found', '群不存在');
|
|
1796
|
+
const fallback = defaultWorkspaceDir(this.config, group);
|
|
1797
|
+
const normalized = normalizeWorkspacePath(value);
|
|
1798
|
+
// The legacy settings form always posts workspaceCwd, including the default.
|
|
1799
|
+
const selected = normalized && !sessionCwdMatches(normalized, fallback) ? normalized : '';
|
|
1800
|
+
const current = group.workspaceCwd || fallback;
|
|
1801
|
+
this.assertWorkspaceStable(groupId);
|
|
1802
|
+
if (sessionCwdMatches(selected || fallback, current) && (group.projectWorkspaceCwd || '') === selected) {
|
|
1803
|
+
return this.withWorkspaceOperation(groupId, () => commit({ workspaceCwd: current, projectWorkspaceCwd: selected }));
|
|
1804
|
+
}
|
|
1805
|
+
if (this.workspaceBusy(groupId)) {
|
|
1806
|
+
throw new ServiceError('busy', '当前聊天有运行、排队或准备中的任务或讨论,请结束后再切换项目目录');
|
|
1807
|
+
}
|
|
1808
|
+
this.workspaceChanges ??= new Set();
|
|
1809
|
+
this.workspaceChanges.add(groupId);
|
|
1810
|
+
try {
|
|
1811
|
+
const projectWorkspaceCwd = selected ? await validateProjectWorkspace(selected) : '';
|
|
1812
|
+
const workspaceCwd = projectWorkspaceCwd || fallback;
|
|
1813
|
+
if (!selected && !(await ensureWorkspaceDir(fallback, this.logger))) {
|
|
1814
|
+
throw new ServiceError('invalid-input', '无法准备默认工作目录');
|
|
1815
|
+
}
|
|
1816
|
+
if (!sessionCwdMatches(current, workspaceCwd)) {
|
|
1817
|
+
// Await disposal before publishing the new root. The session id remains
|
|
1818
|
+
// durable; AgentPool.ensure rotates it when its creation cwd differs.
|
|
1819
|
+
for (const member of this.repo.membersOf(groupId)) {
|
|
1820
|
+
await this.pool.disposeMember(member.membershipId, { strict: true });
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
return await commit({ workspaceCwd, projectWorkspaceCwd });
|
|
1824
|
+
} finally {
|
|
1825
|
+
this.workspaceChanges.delete(groupId);
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
async setGroupWorkspace(input) {
|
|
1830
|
+
const workspaceCwd = normalizeWorkspacePath(input.workspaceCwd);
|
|
1831
|
+
return this.updateGroup({ groupId: input.groupId, workspaceCwd });
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1285
1834
|
async createGroup(input) {
|
|
1286
1835
|
const title = requireText(input.title, '群名称', 120);
|
|
1287
1836
|
if (this.groupChats().length >= this.config.maxGroups) {
|
|
1288
1837
|
throw new ServiceError('limit', `群数量已达上限 ${this.config.maxGroups}`);
|
|
1289
1838
|
}
|
|
1290
|
-
const
|
|
1291
|
-
|
|
1292
|
-
throw new ServiceError('invalid-input', 'workspaceCwd 必须是绝对路径');
|
|
1293
|
-
}
|
|
1839
|
+
const selected = normalizeWorkspacePath(input.workspaceCwd ?? '');
|
|
1840
|
+
const projectWorkspaceCwd = selected ? await validateProjectWorkspace(selected) : '';
|
|
1294
1841
|
const folder = await this.requireFolder(input.folderId);
|
|
1295
1842
|
const avatar = normalizeAvatar(input.avatar);
|
|
1296
1843
|
const groupId = newId('grp');
|
|
1297
|
-
const groupWorkspace =
|
|
1298
|
-
await ensureWorkspaceDir(groupWorkspace, this.logger)
|
|
1844
|
+
const groupWorkspace = projectWorkspaceCwd || defaultWorkspaceDir(this.config, { kind: 'group', groupId });
|
|
1845
|
+
if (!projectWorkspaceCwd && !(await ensureWorkspaceDir(groupWorkspace, this.logger))) {
|
|
1846
|
+
throw new ServiceError('invalid-input', '无法准备默认工作目录');
|
|
1847
|
+
}
|
|
1299
1848
|
const group = {
|
|
1300
1849
|
groupId,
|
|
1301
1850
|
kind: 'group',
|
|
1302
1851
|
title,
|
|
1303
1852
|
topic: optionalText(input.topic, 2000),
|
|
1304
1853
|
workspaceCwd: groupWorkspace,
|
|
1854
|
+
projectWorkspaceCwd,
|
|
1305
1855
|
folderId: folder ? folder.folderId : null,
|
|
1306
1856
|
order: nextOrder(this.groupsInFolder(folder ? folder.folderId : null)),
|
|
1307
1857
|
...(avatar ? { avatar } : {}),
|
|
@@ -1311,43 +1861,58 @@ export class BotTeamService {
|
|
|
1311
1861
|
};
|
|
1312
1862
|
await this.repo.putGroup(group);
|
|
1313
1863
|
this.logger?.info?.(`bot-team: created group ${group.groupId} "${title}"`);
|
|
1314
|
-
return group;
|
|
1864
|
+
return this.repo.getGroup(group.groupId);
|
|
1315
1865
|
}
|
|
1316
1866
|
|
|
1317
1867
|
async updateGroup(input) {
|
|
1318
|
-
const
|
|
1319
|
-
if (!
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
if (input.workspaceCwd !== undefined) {
|
|
1326
|
-
const workspaceCwd = optionalText(input.workspaceCwd, 1024);
|
|
1327
|
-
if (workspaceCwd && !path.isAbsolute(workspaceCwd)) {
|
|
1328
|
-
throw new ServiceError('invalid-input', 'workspaceCwd 必须是绝对路径');
|
|
1868
|
+
const groupId = String(input.groupId || '');
|
|
1869
|
+
if (!this.repo.getGroup(groupId)) throw new ServiceError('not-found', '群不存在');
|
|
1870
|
+
const commit = async (workspace = {}) => {
|
|
1871
|
+
let folder;
|
|
1872
|
+
if (input.folderId !== undefined) {
|
|
1873
|
+
if (isDirectGroup(this.repo.getGroup(groupId))) throw new ServiceError('invalid-input', '单聊不能放进分组');
|
|
1874
|
+
folder = await this.requireFolder(input.folderId);
|
|
1329
1875
|
}
|
|
1330
|
-
|
|
1331
|
-
group.
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
}
|
|
1339
|
-
if (input.folderId !== undefined) {
|
|
1340
|
-
if (isDirectGroup(group)) throw new ServiceError('invalid-input', '单聊不能放进分组');
|
|
1341
|
-
const folder = await this.requireFolder(input.folderId);
|
|
1342
|
-
const nextFolderId = folder ? folder.folderId : null;
|
|
1343
|
-
if (groupFolderIdOf(group) !== nextFolderId) {
|
|
1344
|
-
group.folderId = nextFolderId;
|
|
1345
|
-
group.order = nextOrder(this.groupsInFolder(nextFolderId));
|
|
1876
|
+
// Read after all asynchronous validation so a running turn cannot have its
|
|
1877
|
+
// newly allocated sequence overwritten by a stale group snapshot.
|
|
1878
|
+
const previous = this.repo.getGroup(groupId);
|
|
1879
|
+
if (!previous) throw new ServiceError('not-found', '群不存在');
|
|
1880
|
+
const group = { ...previous, ...workspace };
|
|
1881
|
+
if (input.title !== undefined) {
|
|
1882
|
+
if (isDirectGroup(group)) throw new ServiceError('invalid-input', '单聊名称随 Bot 名称自动更新');
|
|
1883
|
+
group.title = requireText(input.title, '群名称', 120);
|
|
1346
1884
|
}
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1885
|
+
if (input.topic !== undefined) group.topic = optionalText(input.topic, 2000);
|
|
1886
|
+
if (input.avatar !== undefined) {
|
|
1887
|
+
const avatar = normalizeAvatar(input.avatar);
|
|
1888
|
+
if (avatar) group.avatar = avatar;
|
|
1889
|
+
else delete group.avatar;
|
|
1890
|
+
}
|
|
1891
|
+
if (input.folderId !== undefined) {
|
|
1892
|
+
const nextFolderId = folder ? folder.folderId : null;
|
|
1893
|
+
if (groupFolderIdOf(group) !== nextFolderId) {
|
|
1894
|
+
group.folderId = nextFolderId;
|
|
1895
|
+
group.order = nextOrder(this.groupsInFolder(nextFolderId));
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
if (JSON.stringify(group) === JSON.stringify(previous)) return previous;
|
|
1899
|
+
try {
|
|
1900
|
+
await this.repo.putGroup(group);
|
|
1901
|
+
} catch (error) {
|
|
1902
|
+
// putGroup publishes to the in-memory map before its durable write.
|
|
1903
|
+
// Keep failed workspace changes invisible once admission opens again.
|
|
1904
|
+
if (this.repo.getGroup(groupId) === group) {
|
|
1905
|
+
previous.nextSeq = Math.max(previous.nextSeq || 0, group.nextSeq || 0);
|
|
1906
|
+
this.repo.groups.set(groupId, previous);
|
|
1907
|
+
}
|
|
1908
|
+
throw error;
|
|
1909
|
+
}
|
|
1910
|
+
this.bus.emit(groupId, { type: 'group.updated', group });
|
|
1911
|
+
return group;
|
|
1912
|
+
};
|
|
1913
|
+
return input.workspaceCwd !== undefined
|
|
1914
|
+
? this.withWorkspaceChange(groupId, input.workspaceCwd, commit)
|
|
1915
|
+
: this.withWorkspaceOperation(groupId, () => commit());
|
|
1351
1916
|
}
|
|
1352
1917
|
|
|
1353
1918
|
async deleteGroup(input) {
|
|
@@ -1392,6 +1957,7 @@ export class BotTeamService {
|
|
|
1392
1957
|
title: bot.name,
|
|
1393
1958
|
topic: '',
|
|
1394
1959
|
workspaceCwd: defaultWorkspaceDir(this.config, { kind: 'direct', botId: bot.botId }),
|
|
1960
|
+
projectWorkspaceCwd: '',
|
|
1395
1961
|
folderId: null,
|
|
1396
1962
|
order: nextOrder(this.directGroups()),
|
|
1397
1963
|
nextSeq: 0,
|
|
@@ -1416,7 +1982,7 @@ export class BotTeamService {
|
|
|
1416
1982
|
}
|
|
1417
1983
|
if (changed) await this.repo.putGroup(direct);
|
|
1418
1984
|
}
|
|
1419
|
-
await ensureWorkspaceDir(direct.workspaceCwd, this.logger);
|
|
1985
|
+
if (!direct.projectWorkspaceCwd) await ensureWorkspaceDir(direct.workspaceCwd, this.logger);
|
|
1420
1986
|
const members = this.repo.membersOf(direct.groupId);
|
|
1421
1987
|
if (!members.some((member) => member.botId === bot.botId)) {
|
|
1422
1988
|
const at = now();
|
|
@@ -1433,7 +1999,7 @@ export class BotTeamService {
|
|
|
1433
1999
|
};
|
|
1434
2000
|
if (!this.repo.members.has(member.membershipId)) await this.repo.putMember(member);
|
|
1435
2001
|
}
|
|
1436
|
-
return direct;
|
|
2002
|
+
return this.repo.getGroup(direct.groupId);
|
|
1437
2003
|
}
|
|
1438
2004
|
|
|
1439
2005
|
async openDirect(input) {
|
|
@@ -1548,6 +2114,7 @@ export class BotTeamService {
|
|
|
1548
2114
|
const provider = requireText(input.provider, 'provider', 80);
|
|
1549
2115
|
const model = requireText(input.model, 'model', 160);
|
|
1550
2116
|
const avatar = normalizeAvatar(input.avatar);
|
|
2117
|
+
const signature = optionalText(input.signature, 120);
|
|
1551
2118
|
const reasoningEffort = optionalText(input.reasoningEffort, 80);
|
|
1552
2119
|
const maxTokensRaw = Number(input.maxTokens);
|
|
1553
2120
|
const bot = {
|
|
@@ -1559,6 +2126,7 @@ export class BotTeamService {
|
|
|
1559
2126
|
// Retained in the record schema for compatibility; every bot is full-capability.
|
|
1560
2127
|
capability: 'work',
|
|
1561
2128
|
...(avatar ? { avatar } : {}),
|
|
2129
|
+
...(signature ? { signature } : {}),
|
|
1562
2130
|
...(reasoningEffort ? { reasoningEffort } : {}),
|
|
1563
2131
|
...(Number.isInteger(maxTokensRaw) && maxTokensRaw > 0 ? { maxTokens: Math.min(maxTokensRaw, 200000) } : {}),
|
|
1564
2132
|
createdAt: now(),
|
|
@@ -1591,6 +2159,11 @@ export class BotTeamService {
|
|
|
1591
2159
|
if (avatar) bot.avatar = avatar;
|
|
1592
2160
|
else delete bot.avatar;
|
|
1593
2161
|
}
|
|
2162
|
+
if (input.signature !== undefined) {
|
|
2163
|
+
const signature = optionalText(input.signature, 120);
|
|
2164
|
+
if (signature) bot.signature = signature;
|
|
2165
|
+
else delete bot.signature;
|
|
2166
|
+
}
|
|
1594
2167
|
if (input.reasoningEffort !== undefined) {
|
|
1595
2168
|
const effort = optionalText(input.reasoningEffort, 80);
|
|
1596
2169
|
if (effort) bot.reasoningEffort = effort;
|
|
@@ -1666,9 +2239,9 @@ export class BotTeamService {
|
|
|
1666
2239
|
createdAt: now(),
|
|
1667
2240
|
updatedAt: now(),
|
|
1668
2241
|
};
|
|
1669
|
-
await this.repo.putMember(member);
|
|
1670
|
-
this.bus.emit(group.groupId, { type: 'member.added', member });
|
|
1671
|
-
return
|
|
2242
|
+
const saved = await this.repo.putMember(member);
|
|
2243
|
+
this.bus.emit(group.groupId, { type: 'member.added', member: saved });
|
|
2244
|
+
return saved;
|
|
1672
2245
|
}
|
|
1673
2246
|
|
|
1674
2247
|
async removeMember(input) {
|
|
@@ -1688,16 +2261,20 @@ export class BotTeamService {
|
|
|
1688
2261
|
if (!member) throw new ServiceError('not-found', '成员不存在');
|
|
1689
2262
|
if (input.enabled !== undefined) member.enabled = input.enabled !== false;
|
|
1690
2263
|
if (Number.isInteger(input.order)) member.order = input.order;
|
|
1691
|
-
await this.repo.putMember(member);
|
|
1692
|
-
this.bus.emit(member.groupId, { type: 'member.updated', member });
|
|
1693
|
-
return
|
|
2264
|
+
const saved = await this.repo.putMember(member);
|
|
2265
|
+
this.bus.emit(member.groupId, { type: 'member.updated', member: saved });
|
|
2266
|
+
return saved;
|
|
1694
2267
|
}
|
|
1695
2268
|
|
|
1696
2269
|
async postMessage(input) {
|
|
1697
2270
|
const groupId = String(input.groupId || '');
|
|
1698
2271
|
const group = this.repo.getGroup(groupId);
|
|
1699
2272
|
if (!group) throw new ServiceError('not-found', '群不存在');
|
|
1700
|
-
const text =
|
|
2273
|
+
const text = String(input.text ?? '').trim();
|
|
2274
|
+
if (text.length > this.config.maxMessageChars) throw new ServiceError('invalid-input', '消息超过长度上限');
|
|
2275
|
+
if (text === '/new') throw new ServiceError('command-required', '请使用新建会话指令入口');
|
|
2276
|
+
if (/^[/]new\s/.test(text)) throw new ServiceError('invalid-command', '请单独发送 /new');
|
|
2277
|
+
this.assertExpectedConversation(groupId, input.expectedConversationId);
|
|
1701
2278
|
const attachments = this.normalizeMessageAttachments(input.attachments);
|
|
1702
2279
|
if (!text && attachments.length === 0) throw new ServiceError('invalid-input', '消息不能为空');
|
|
1703
2280
|
const groupMembers = this.repo.membersOf(groupId);
|
|
@@ -1714,7 +2291,50 @@ export class BotTeamService {
|
|
|
1714
2291
|
mentions = parseMentionsFromText(this.repo, groupId, text);
|
|
1715
2292
|
}
|
|
1716
2293
|
const replyTo = typeof input.replyTo === 'string' && input.replyTo ? input.replyTo : null;
|
|
1717
|
-
return this.engine.postHumanMessage({ groupId, text, mentions, replyTo, attachments });
|
|
2294
|
+
return this.engine.postHumanMessage({ groupId, text, mentions, replyTo, attachments, expectedConversationId: input.expectedConversationId ?? currentConversationId(this.repo, groupId) });
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
async deleteMessage(input) {
|
|
2298
|
+
const groupId = String(input.groupId || '');
|
|
2299
|
+
const messageId = String(input.messageId || '');
|
|
2300
|
+
const group = this.repo.getGroup(groupId);
|
|
2301
|
+
const message = this.repo.getMessage(messageId);
|
|
2302
|
+
if (!group || !message || message.groupId !== groupId) {
|
|
2303
|
+
throw new ServiceError('not-found', '消息不存在或不属于本聊天');
|
|
2304
|
+
}
|
|
2305
|
+
if (['system', 'approval'].includes(message.kind) || !['human', 'bot'].includes(message.author?.type)) {
|
|
2306
|
+
throw new ServiceError('invalid-input', '只能删除普通聊天消息');
|
|
2307
|
+
}
|
|
2308
|
+
if (message.status === 'pending' || message.status === 'streaming') {
|
|
2309
|
+
throw new ServiceError('busy', '消息正在生成,请结束后再删除');
|
|
2310
|
+
}
|
|
2311
|
+
if (!['done', 'error', 'stopped'].includes(message.status ?? 'done')) {
|
|
2312
|
+
throw new ServiceError('invalid-input', '消息状态不支持删除');
|
|
2313
|
+
}
|
|
2314
|
+
// A native goal may have a done reply while waiting for its next turn, and
|
|
2315
|
+
// a stopped job may still be finishing durable writes. Status alone is not
|
|
2316
|
+
// enough: its current placeholder must survive until that work releases it.
|
|
2317
|
+
// Queued/preparing turns still need their trigger and review inputs when
|
|
2318
|
+
// constructing the prompt, so keep those records until the job settles.
|
|
2319
|
+
const jobs = [
|
|
2320
|
+
...(this.engine.running?.values() ?? []),
|
|
2321
|
+
...(this.engine.activeBySession?.values() ?? []),
|
|
2322
|
+
...(this.engine.pendingWork ?? []),
|
|
2323
|
+
...(this.engine.queue ?? []),
|
|
2324
|
+
];
|
|
2325
|
+
if (jobs.some((job) =>
|
|
2326
|
+
[job.placeholderId, job.triggerMessageId, job.reviewTargetId, job.reviewMessageId].includes(messageId) ||
|
|
2327
|
+
(job.kind === 'revision' && this.repo.getMessage(job.reviewMessageId)?.meta?.reviewOf === messageId))) {
|
|
2328
|
+
throw new ServiceError('busy', '消息仍被执行中的任务使用,请结束后再删除');
|
|
2329
|
+
}
|
|
2330
|
+
if (!await this.repo.deleteMessageStrict(messageId)) {
|
|
2331
|
+
throw new ServiceError('not-found', '消息不存在或不属于本聊天');
|
|
2332
|
+
}
|
|
2333
|
+
this.bus.emit(groupId, { type: 'message.deleted', groupId, messageId });
|
|
2334
|
+
// Keep the existing group update signal; message.deleted consumers refresh
|
|
2335
|
+
// bootstrap for the preview. Deletion must not count as new group activity.
|
|
2336
|
+
this.bus.emit(groupId, { type: 'group.updated', group });
|
|
2337
|
+
return { groupId, messageId, deleted: true };
|
|
1718
2338
|
}
|
|
1719
2339
|
|
|
1720
2340
|
async stopGroup(input) {
|
|
@@ -1808,31 +2428,18 @@ export class BotTeamService {
|
|
|
1808
2428
|
return value;
|
|
1809
2429
|
}
|
|
1810
2430
|
|
|
1811
|
-
/** Per-group usage guard:
|
|
1812
|
-
checkQuota(groupId
|
|
1813
|
-
const
|
|
1814
|
-
const dayStart = new Date(nowMs);
|
|
2431
|
+
/** Per-group usage guard: daily token budget, reset at local midnight. */
|
|
2432
|
+
checkQuota(groupId) {
|
|
2433
|
+
const dayStart = new Date(Date.now());
|
|
1815
2434
|
dayStart.setHours(0, 0, 0, 0);
|
|
1816
|
-
const hourAgo = nowMs - 3600 * 1000;
|
|
1817
2435
|
const dayStartMs = dayStart.getTime();
|
|
1818
|
-
let turnsLastHour = 0;
|
|
1819
2436
|
let tokensToday = 0;
|
|
1820
2437
|
for (const message of this.repo.messagesOf(groupId)) {
|
|
1821
2438
|
if (!message.createdAt) continue;
|
|
1822
|
-
const isBot = message.author && message.author.type === 'bot' && message.status === 'done';
|
|
1823
|
-
if (isBot && message.createdAt >= hourAgo) turnsLastHour += 1;
|
|
1824
2439
|
if (message.createdAt >= dayStartMs && message.usage) {
|
|
1825
2440
|
tokensToday += (message.usage.totalTokens ?? ((message.usage.inputTokens ?? 0) + (message.usage.outputTokens ?? 0)));
|
|
1826
2441
|
}
|
|
1827
2442
|
}
|
|
1828
|
-
if (turnsLastHour + turns > this.config.maxTurnsPerHour) {
|
|
1829
|
-
return {
|
|
1830
|
-
ok: false,
|
|
1831
|
-
reason: '本群最近一小时已触发 ' + turnsLastHour + ' 轮回复(上限 ' + this.config.maxTurnsPerHour + '),请稍候再试',
|
|
1832
|
-
turnsLastHour,
|
|
1833
|
-
limit: this.config.maxTurnsPerHour,
|
|
1834
|
-
};
|
|
1835
|
-
}
|
|
1836
2443
|
if (tokensToday >= this.config.dailyTokenBudget) {
|
|
1837
2444
|
return {
|
|
1838
2445
|
ok: false,
|
|
@@ -1841,7 +2448,7 @@ export class BotTeamService {
|
|
|
1841
2448
|
limit: this.config.dailyTokenBudget,
|
|
1842
2449
|
};
|
|
1843
2450
|
}
|
|
1844
|
-
return { ok: true,
|
|
2451
|
+
return { ok: true, tokensToday, tokenLimit: this.config.dailyTokenBudget };
|
|
1845
2452
|
}
|
|
1846
2453
|
|
|
1847
2454
|
healthPayload() {
|
|
@@ -1854,14 +2461,13 @@ export class BotTeamService {
|
|
|
1854
2461
|
config: {
|
|
1855
2462
|
botSandbox: this.config.botSandbox,
|
|
1856
2463
|
botApprovalPolicy: this.config.botApprovalPolicy,
|
|
1857
|
-
maxTurnsPerHour: this.config.maxTurnsPerHour,
|
|
1858
2464
|
dailyTokenBudget: this.config.dailyTokenBudget,
|
|
1859
2465
|
maxParallelTurns: this.config.maxParallelTurns,
|
|
1860
2466
|
enableModelTools: this.config.enableModelTools,
|
|
1861
2467
|
},
|
|
1862
2468
|
toolNames: BOT_TEAM_TOOL_NAMES,
|
|
1863
2469
|
quotas: Object.fromEntries(
|
|
1864
|
-
[...this.repo.groups.values()].map((group) => [group.groupId, this.checkQuota(group.groupId
|
|
2470
|
+
[...this.repo.groups.values()].map((group) => [group.groupId, this.checkQuota(group.groupId)]),
|
|
1865
2471
|
),
|
|
1866
2472
|
smoke: this.smokeState,
|
|
1867
2473
|
at: Date.now(),
|
|
@@ -2354,8 +2960,15 @@ export class BotTeamService {
|
|
|
2354
2960
|
health.domain.schemaVersion >= 6 &&
|
|
2355
2961
|
health.domain.counts.messages > 0 &&
|
|
2356
2962
|
health.toolNames.length === BOT_TEAM_TOOL_NAMES.length;
|
|
2357
|
-
|
|
2358
|
-
|
|
2963
|
+
const quota = this.checkQuota(group.groupId);
|
|
2964
|
+
quotaPass = quota.ok === true;
|
|
2965
|
+
const dailyTokenBudget = this.config.dailyTokenBudget;
|
|
2966
|
+
try {
|
|
2967
|
+
this.config.dailyTokenBudget = quota.tokensToday;
|
|
2968
|
+
quotaDenyPass = this.checkQuota(group.groupId).ok === false;
|
|
2969
|
+
} finally {
|
|
2970
|
+
this.config.dailyTokenBudget = dailyTokenBudget;
|
|
2971
|
+
}
|
|
2359
2972
|
const agentA2 = this.pool.current(memberA.membershipId);
|
|
2360
2973
|
if (agentA2 && agentA2.session) {
|
|
2361
2974
|
const modes = agentA2.session
|
|
@@ -2555,6 +3168,7 @@ export class BotTeamService {
|
|
|
2555
3168
|
async dispose() {
|
|
2556
3169
|
if (this.disposed) return;
|
|
2557
3170
|
this.disposed = true;
|
|
3171
|
+
this.updateChecker?.dispose();
|
|
2558
3172
|
try {
|
|
2559
3173
|
await this.routeDispose?.();
|
|
2560
3174
|
} catch {
|
|
@@ -2565,6 +3179,16 @@ export class BotTeamService {
|
|
|
2565
3179
|
} catch {
|
|
2566
3180
|
/* noop */
|
|
2567
3181
|
}
|
|
3182
|
+
try {
|
|
3183
|
+
await this.interactions?.dispose();
|
|
3184
|
+
} catch {
|
|
3185
|
+
/* noop */
|
|
3186
|
+
}
|
|
3187
|
+
try {
|
|
3188
|
+
await this.visibility?.dispose();
|
|
3189
|
+
} catch {
|
|
3190
|
+
/* noop */
|
|
3191
|
+
}
|
|
2568
3192
|
try {
|
|
2569
3193
|
await this.engine?.dispose();
|
|
2570
3194
|
} catch {
|