dsh-bot-team 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/host/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 { readMessageExecution } from './execution.js';
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.1.0',
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 robot mascots, art IDs are the bundled SVG pack.
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(['planet', 'rocket', 'cloud', 'bolt', 'leaf', 'flame', 'star', 'moon', 'wave', 'flower']);
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 id = text.slice('art:'.length);
94
- if (!AVATAR_ART_IDS.has(id)) throw new ServiceError('invalid-input', '未知的插画头像');
95
- return `art:${id}`;
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, turns) => this.checkQuota(groupId, turns),
176
- taskHook: (job, message, status) => this.onTaskJobFinished(job, message, status),
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((discussion) => discussion.status === 'running') || null;
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
- return json(fail(code, String(error && error.message ? error.message : error)), code === 'internal' ? 500 : 400);
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
- async dispatch(action, payload = {}) {
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.engine.stopDiscussion(String(input.discussionId || ''));
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).map((task) => this.taskView(task));
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, turns) {
1258
+ requireTaskQuota(groupId) {
910
1259
  try {
911
- this.engine.assertQuota(groupId, turns);
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, 1);
1000
- task.status = 'in_progress';
1001
- await this.repo.putTask(task);
1002
- this.emitTask(task);
1003
- this.engine.enqueueTaskTurn({ taskId: task.taskId, phase: 'dispatch' });
1004
- return task;
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, 1);
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.status = 'accepted';
1042
- task.acceptedAt = now();
1043
- await this.repo.putTask(task);
1044
- this.emitTask(task);
1045
- await this.unblockDependents(task);
1046
- return this.taskView(task);
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, 1);
1106
- task.status = 'in_progress';
1107
- await this.repo.putTask(task);
1108
- this.emitTask(task);
1109
- this.engine.enqueueTaskTurn({ taskId: task.taskId, phase: 'revision' });
1110
- return task;
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
- return this.engine.requestReview({ groupId, targetMessageId, reviewerMembershipId, instructions });
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), 5) : 2;
1220
- const membershipIds = Array.isArray(input.membershipIds)
1221
- ? input.membershipIds.filter((value) => typeof value === 'string')
1222
- : this.repo.membersOf(groupId).map((member) => member.membershipId);
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
- topic,
1227
- membershipIds,
1228
- rounds,
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 && error.code) throw new ServiceError(error.code, String(error.message));
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 workspaceCwd = optionalText(input.workspaceCwd, 1024);
1291
- if (workspaceCwd && !path.isAbsolute(workspaceCwd)) {
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 = workspaceCwd || defaultWorkspaceDir(this.config, { kind: 'group', groupId });
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 group = this.repo.getGroup(String(input.groupId || ''));
1319
- if (!group) throw new ServiceError('not-found', '群不存在');
1320
- if (input.title !== undefined) {
1321
- if (isDirectGroup(group)) throw new ServiceError('invalid-input', '单聊名称随 Bot 名称自动更新');
1322
- group.title = requireText(input.title, '群名称', 120);
1323
- }
1324
- if (input.topic !== undefined) group.topic = optionalText(input.topic, 2000);
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
- const nextWorkspace = workspaceCwd || defaultWorkspaceDir(this.config, group);
1331
- group.workspaceCwd = nextWorkspace;
1332
- await ensureWorkspaceDir(nextWorkspace, this.logger);
1333
- }
1334
- if (input.avatar !== undefined) {
1335
- const avatar = normalizeAvatar(input.avatar);
1336
- if (avatar) group.avatar = avatar;
1337
- else delete group.avatar;
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
- await this.repo.putGroup(group);
1349
- this.bus.emit(group.groupId, { type: 'group.updated', group });
1350
- return group;
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 member;
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 member;
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 = asString(input.text, this.config.maxMessageChars).trim();
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: hourly bot turns + daily token budget. */
1812
- checkQuota(groupId, turns = 1) {
1813
- const nowMs = Date.now();
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, turnsLastHour, tokensToday, turnsLimit: this.config.maxTurnsPerHour, tokenLimit: this.config.dailyTokenBudget };
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, 0)]),
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
- quotaPass = this.checkQuota(group.groupId, 1).ok === true;
2358
- quotaDenyPass = this.checkQuota(group.groupId, 999999).ok === false;
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 {