@xmoxmo/bncr 0.6.2 → 0.6.3

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.
@@ -1,7 +1,10 @@
1
- import { DEFAULT_GROUP_HISTORY_LIMIT as DEFAULT_HISTORY_LIMIT } from 'openclaw/plugin-sdk/reply-history';
2
1
  import { emitBncrLogLine } from '../../core/logging.ts';
2
+ import { parseStrictBncrSessionKey } from '../../core/targets.ts';
3
+ import type { OutboxEntry } from '../../core/types.ts';
3
4
  import type { BncrSceneRecord } from '../../plugin/channel-runtime-types.ts';
4
5
  import { buildSceneKey } from '../../plugin/scene-registry.ts';
6
+ import type { BncrHistoryShardQueue } from '../../plugin/sqlite-state.ts';
7
+ import { getBncrHistoryShardQueue } from '../../plugin/state-store.ts';
5
8
  import { handleBncrNativeCommand } from './commands.ts';
6
9
  import type {
7
10
  BncrEnqueueFromReply,
@@ -14,11 +17,18 @@ import {
14
17
  type BncrConversationHistoryMap,
15
18
  type BncrHistoryEntry,
16
19
  buildBncrConversationHistoryKey,
17
- clearBncrPendingConversationHistory,
18
20
  readBncrPendingConversationHistorySnapshot,
21
+ readConversationHistoryVersion,
19
22
  recordBncrPendingConversationMedia,
20
23
  recordBncrPendingConversationText,
24
+ removeBncrConversationHistoryMessageIds,
25
+ resolveBncrConversationHistoryMessageId,
26
+ resolveBncrHistoryLimit,
21
27
  } from './conversation-history.ts';
28
+ import {
29
+ type ConversationHistorySerialHandle,
30
+ runConversationHistorySerial,
31
+ } from './conversation-history-serial.ts';
22
32
  import {
23
33
  assertInboundMediaBase64Size,
24
34
  decodeInboundMediaBase64,
@@ -27,11 +37,16 @@ import {
27
37
  prepareBncrInboundDispatch,
28
38
  resolveBncrInboundConversation,
29
39
  } from './dispatch-prep.ts';
40
+ import { runBncrHistoryShardWithLeaseRenewal } from './history-shard-worker.ts';
41
+ import { isBncrStopCommandText } from './native-command.ts';
30
42
  import {
31
43
  type BncrOutboundReplayCache,
32
- clearBncrOutboundReplay,
44
+ type BncrOutboundReplayEntry,
45
+ buildBncrOutboundReplayKey,
33
46
  readBncrOutboundReplaySnapshot,
47
+ removeBncrOutboundReplayMessageIds,
34
48
  } from './outbound-replay-cache.ts';
49
+ import { parseBncrInboundParams } from './parse.ts';
35
50
  import { runBncrInboundReplyDispatch } from './reply-dispatch.ts';
36
51
  import { buildBncrInboundTurnContext } from './turn-context.ts';
37
52
 
@@ -50,10 +65,14 @@ export async function dispatchBncrInbound(params: {
50
65
  canonicalAgentId: string;
51
66
  shouldDispatch?: boolean;
52
67
  shouldAccumulate?: boolean;
68
+ forceSilentHistoryFlush?: boolean;
69
+ forceSilentHistoryFlushVersion?: number;
70
+ forceSilentHistoryFlushMessageId?: string;
53
71
  resolvedAgentId?: string;
54
72
  sceneRegistry: Map<string, BncrSceneRecord>;
55
73
  conversationHistories: BncrConversationHistoryMap;
56
74
  outboundReplayCache?: BncrOutboundReplayCache;
75
+ historyShardQueue?: BncrHistoryShardQueue;
57
76
  defaultAdminAgentId: string;
58
77
  defaultPublicAgentId: string;
59
78
  now: () => number;
@@ -71,6 +90,9 @@ export async function dispatchBncrInbound(params: {
71
90
  canonicalAgentId,
72
91
  shouldDispatch: initialShouldDispatch = true,
73
92
  shouldAccumulate = initialShouldDispatch,
93
+ forceSilentHistoryFlush = false,
94
+ forceSilentHistoryFlushVersion,
95
+ forceSilentHistoryFlushMessageId,
74
96
  resolvedAgentId,
75
97
  sceneRegistry = new Map(),
76
98
  conversationHistories = new Map(),
@@ -84,8 +106,10 @@ export async function dispatchBncrInbound(params: {
84
106
  scheduleSave,
85
107
  logger,
86
108
  } = params;
109
+ const historyShardQueue =
110
+ params.historyShardQueue ?? getBncrHistoryShardQueue(conversationHistories) ?? undefined;
87
111
  const { accountId, clientId, msgId, extracted, mimeType, peer } = parsed;
88
- let shouldDispatch = initialShouldDispatch;
112
+ let shouldDispatch = initialShouldDispatch || forceSilentHistoryFlush;
89
113
 
90
114
  const nativeCommand = await handleBncrNativeCommand({
91
115
  api,
@@ -143,168 +167,564 @@ export async function dispatchBncrInbound(params: {
143
167
  '[bncr] inbound missing clientId for chat identity; using route identity fallback',
144
168
  );
145
169
  }
146
- const sceneHistoryLimit =
147
- sceneRegistry.get(buildSceneKey(parsed))?.historyLimit ?? DEFAULT_HISTORY_LIMIT;
148
- const resolvedHistoryLimit =
149
- typeof sceneHistoryLimit === 'number' &&
150
- Number.isFinite(sceneHistoryLimit) &&
151
- sceneHistoryLimit >= 0
152
- ? Math.floor(sceneHistoryLimit)
153
- : DEFAULT_HISTORY_LIMIT;
154
-
155
- let silentHistoryFlush = false;
156
- let pendingHistoryEntries: BncrHistoryEntry[] = [];
157
- if (shouldAccumulate) {
158
- const historyKey = buildBncrConversationHistoryKey(parsed);
159
- // Record the message first so overflow context includes it
160
- recordBncrPendingConversationText({
161
- historyMap: conversationHistories,
170
+ const historyKey = buildBncrConversationHistoryKey(parsed);
171
+ const resolvedHistoryLimit = resolveBncrHistoryLimit(
172
+ sceneRegistry.get(buildSceneKey(parsed))?.historyLimit,
173
+ );
174
+ const isStopCommand = isBncrStopCommandText(rawBody);
175
+ if (isStopCommand && shouldDispatch) {
176
+ // A stop command must not wait behind the history serial chain or mutate
177
+ // the accumulated conversation window. Deliver it immediately so the host
178
+ // can abort the active run instead of queuing behind it.
179
+ const ctxPayload = await buildBncrInboundTurnContext({
180
+ api,
181
+ cfg,
182
+ channelId,
162
183
  parsed,
184
+ msgId,
185
+ peer,
186
+ senderIdForContext,
163
187
  senderDisplayName,
164
- senderId: senderIdForContext,
165
- bodyText: rawBody,
188
+ ownerAllowFrom,
189
+ bridgeSenderId,
190
+ bridgeSenderName,
166
191
  historyLimit: resolvedHistoryLimit,
192
+ resolution,
193
+ prepared,
194
+ conversationHistories: new Map(),
195
+ shouldDispatch: true,
196
+ silentHistoryFlush: false,
197
+ pendingHistoryEntries: [],
167
198
  });
168
- await recordBncrPendingConversationMedia({
169
- historyMap: conversationHistories,
199
+ await runBncrInboundReplyDispatch({
200
+ api,
201
+ channelId,
202
+ cfg,
170
203
  parsed,
204
+ msgId,
205
+ peer,
206
+ rawBody,
207
+ storePath,
208
+ ctxPayload,
209
+ resolution,
210
+ replyRouteFact,
211
+ senderIdForContext,
171
212
  senderDisplayName,
172
- senderId: senderIdForContext,
173
- bodyText: rawBody,
174
- mediaItems,
175
- mediaContentType: mediaContentType || mimeType,
176
- historyLimit: resolvedHistoryLimit,
213
+ shouldDispatch: true,
214
+ silentHistoryFlush: false,
215
+ setInboundActivity,
216
+ scheduleSave,
217
+ enqueueFromReply,
177
218
  });
178
- if (historyKey) {
179
- const entries = conversationHistories.get(historyKey);
180
- if (entries && resolvedHistoryLimit > 0 && entries.length >= resolvedHistoryLimit) {
181
- const sceneForHistory = sceneRegistry.get(buildSceneKey(parsed));
182
- const historyForce = sceneForHistory?.historyForce !== false;
183
- if (historyForce) {
184
- silentHistoryFlush = true;
185
- shouldDispatch = true;
219
+ scheduleSave();
220
+ return {
221
+ accountId,
222
+ sessionKey,
223
+ taskKey: extracted.taskKey ?? null,
224
+ msgId: msgId ?? null,
225
+ };
226
+ }
227
+ const processTurn = async (handle: ConversationHistorySerialHandle) => {
228
+ handle.phase('snapshot');
229
+ const serialOwner = handle.owner();
230
+ let silentHistoryFlush = forceSilentHistoryFlush;
231
+ let pendingHistoryEntries: BncrHistoryEntry[] = [];
232
+ let snapshotMessageIds: string[] = [];
233
+ let cacheKey: string | undefined;
234
+ let historyShardId: number | null = null;
235
+ let historyShardDeliveryId: string | undefined;
236
+ let uploadSettled = false;
237
+ if (historyKey && forceSilentHistoryFlushVersion !== undefined) {
238
+ const currentHistoryVersion = readConversationHistoryVersion(
239
+ conversationHistories,
240
+ historyKey,
241
+ );
242
+ if (currentHistoryVersion !== forceSilentHistoryFlushVersion) {
243
+ scheduleSave();
244
+ return {
245
+ accountId,
246
+ sessionKey,
247
+ taskKey: extracted.taskKey ?? null,
248
+ msgId: msgId ?? null,
249
+ };
250
+ }
251
+ }
252
+ if (shouldAccumulate) {
253
+ // Record the message first so overflow context includes it
254
+ recordBncrPendingConversationText({
255
+ historyMap: conversationHistories,
256
+ parsed,
257
+ senderDisplayName,
258
+ senderId: senderIdForContext,
259
+ bodyText: rawBody,
260
+ historyLimit: resolvedHistoryLimit,
261
+ });
262
+ recordBncrPendingConversationMedia({
263
+ historyMap: conversationHistories,
264
+ parsed,
265
+ senderDisplayName,
266
+ senderId: senderIdForContext,
267
+ bodyText: rawBody,
268
+ mediaItems,
269
+ mediaContentType: mediaContentType || mimeType,
270
+ historyLimit: resolvedHistoryLimit,
271
+ });
272
+ if (historyKey) {
273
+ const entries = conversationHistories.get(historyKey);
274
+ if (entries && resolvedHistoryLimit > 0 && entries.length >= resolvedHistoryLimit) {
275
+ const sceneForHistory = sceneRegistry.get(buildSceneKey(parsed));
276
+ const historyForce = sceneForHistory?.historyForce !== false;
277
+ if (historyForce) {
278
+ silentHistoryFlush = true;
279
+ shouldDispatch = true;
280
+ }
186
281
  }
187
282
  }
188
283
  }
189
- }
190
284
 
191
- if (shouldDispatch) {
192
- pendingHistoryEntries = readBncrPendingConversationHistorySnapshot({
193
- historyMap: conversationHistories,
194
- parsed,
195
- historyLimit: resolvedHistoryLimit,
196
- });
197
- if (pendingHistoryEntries.length > 0) {
198
- clearBncrPendingConversationHistory({
285
+ if (historyKey && historyShardQueue?.reconcileHistoryMemory) {
286
+ await historyShardQueue.reconcileHistoryMemory(historyKey);
287
+ }
288
+
289
+ if (shouldDispatch) {
290
+ pendingHistoryEntries = readBncrPendingConversationHistorySnapshot({
199
291
  historyMap: conversationHistories,
200
292
  parsed,
201
293
  historyLimit: resolvedHistoryLimit,
202
294
  });
295
+ const legacyOutboundEntries = readBncrOutboundReplaySnapshot({
296
+ cache: outboundReplayCache,
297
+ parsed,
298
+ accountId,
299
+ excludeMessageId: msgId,
300
+ });
301
+ if (
302
+ forceSilentHistoryFlush &&
303
+ pendingHistoryEntries.length === 0 &&
304
+ legacyOutboundEntries.length === 0
305
+ ) {
306
+ scheduleSave();
307
+ return {
308
+ accountId,
309
+ sessionKey,
310
+ taskKey: extracted.taskKey ?? null,
311
+ msgId: msgId ?? null,
312
+ };
313
+ }
314
+ const knownMessageIds = new Set(
315
+ pendingHistoryEntries
316
+ .map((entry) => entry.messageId)
317
+ .filter((messageId): messageId is string => Boolean(messageId)),
318
+ );
319
+ for (const entry of legacyOutboundEntries) {
320
+ if (entry.messageId && knownMessageIds.has(entry.messageId)) continue;
321
+ if (entry.messageId) knownMessageIds.add(entry.messageId);
322
+ pendingHistoryEntries.push({
323
+ sender: entry.sender,
324
+ ...(entry.senderId ? { senderId: entry.senderId } : {}),
325
+ body: entry.body,
326
+ ...(typeof entry.timestamp === 'number' ? { timestamp: entry.timestamp } : {}),
327
+ ...(entry.messageId ? { messageId: entry.messageId } : {}),
328
+ role: 'assistant',
329
+ ...(Array.isArray(entry.media)
330
+ ? {
331
+ media: entry.media.map((item) => ({
332
+ path: item.path,
333
+ contentType: item.contentType,
334
+ kind: item.kind,
335
+ messageId: item.messageId,
336
+ })),
337
+ }
338
+ : {}),
339
+ });
340
+ }
341
+ pendingHistoryEntries.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
342
+ if (pendingHistoryEntries.length > resolvedHistoryLimit) {
343
+ let currentMessageId = String(
344
+ (forceSilentHistoryFlush && forceSilentHistoryFlushMessageId) || '',
345
+ ).trim();
346
+ if (!currentMessageId) {
347
+ // A platform may omit message ids. The latest recorded in-memory entry
348
+ // is the current message and must survive the window trim even when
349
+ // other entries carry later timestamps.
350
+ currentMessageId = String(
351
+ resolveBncrConversationHistoryMessageId(parsed) ||
352
+ (shouldAccumulate
353
+ ? historyKey
354
+ ? conversationHistories.get(historyKey)?.at(-1)?.messageId
355
+ : undefined
356
+ : undefined) ||
357
+ '',
358
+ ).trim();
359
+ }
360
+ const currentEntry = currentMessageId
361
+ ? pendingHistoryEntries.find((entry) => entry.messageId === currentMessageId)
362
+ : undefined;
363
+ pendingHistoryEntries = pendingHistoryEntries.slice(-resolvedHistoryLimit);
364
+ if (currentEntry && !pendingHistoryEntries.includes(currentEntry)) {
365
+ const withoutCurrent = pendingHistoryEntries.filter((entry) => entry !== currentEntry);
366
+ pendingHistoryEntries = [
367
+ ...withoutCurrent.slice(-(resolvedHistoryLimit - 1)),
368
+ currentEntry,
369
+ ];
370
+ pendingHistoryEntries.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
371
+ }
372
+ }
373
+ cacheKey = buildBncrOutboundReplayKey(parsed, accountId) ?? undefined;
374
+ const historyEntriesBeforeUpload: readonly BncrHistoryEntry[] = historyKey
375
+ ? conversationHistories.get(historyKey) || []
376
+ : [];
377
+ const cacheEntriesBeforeUpload: readonly BncrOutboundReplayEntry[] = cacheKey
378
+ ? outboundReplayCache.get(cacheKey) || []
379
+ : [];
380
+ snapshotMessageIds = Array.from(
381
+ new Set(
382
+ [
383
+ ...historyEntriesBeforeUpload.map((entry) => entry.messageId),
384
+ ...cacheEntriesBeforeUpload.map((entry) => entry.messageId),
385
+ ].filter((messageId): messageId is string => Boolean(messageId)),
386
+ ),
387
+ );
388
+ handle.setCleanup(() => {
389
+ const shouldClearLocalCache = historyShardId === null || uploadSettled;
390
+ if (historyKey && shouldClearLocalCache) {
391
+ removeBncrConversationHistoryMessageIds({
392
+ historyMap: conversationHistories,
393
+ historyKey,
394
+ messageIds: snapshotMessageIds,
395
+ });
396
+ }
397
+ if (shouldClearLocalCache) {
398
+ removeBncrOutboundReplayMessageIds({
399
+ cache: outboundReplayCache,
400
+ parsed,
401
+ accountId,
402
+ messageIds: snapshotMessageIds,
403
+ });
404
+ }
405
+ if (uploadSettled && historyShardId !== null && historyShardQueue) {
406
+ try {
407
+ historyShardQueue.completeHistoryShard(historyShardId, serialOwner);
408
+ } catch (error) {
409
+ emitBncrLogLine(
410
+ 'warn',
411
+ `[bncr] history shard cleanup failed|key=${historyKey}|shard=${historyShardId}|reason=${String(error)}`,
412
+ );
413
+ }
414
+ }
415
+ });
416
+ handle.phase('snapshot', {
417
+ snapshotMessageIds,
418
+ cacheKey,
419
+ });
203
420
  }
204
- const legacyOutboundEntries = readBncrOutboundReplaySnapshot({
205
- cache: outboundReplayCache,
206
- conversationHistories,
421
+ const ctxPayload = await buildBncrInboundTurnContext({
422
+ api,
423
+ cfg,
424
+ channelId,
207
425
  parsed,
208
- accountId,
209
- excludeMessageId: msgId,
426
+ msgId,
427
+ peer,
428
+ senderIdForContext,
429
+ senderDisplayName,
430
+ ownerAllowFrom,
431
+ bridgeSenderId,
432
+ bridgeSenderName,
433
+ historyLimit: resolvedHistoryLimit,
434
+ resolution,
435
+ prepared,
436
+ conversationHistories,
437
+ shouldDispatch,
438
+ silentHistoryFlush,
439
+ pendingHistoryEntries,
210
440
  });
211
- clearBncrOutboundReplay({
212
- cache: outboundReplayCache,
213
- parsed,
214
- accountId,
441
+ if (handle.isAbandoned()) {
442
+ return {
443
+ accountId,
444
+ sessionKey,
445
+ taskKey: extracted.taskKey ?? null,
446
+ msgId: msgId ?? null,
447
+ };
448
+ }
449
+ if (!shouldDispatch) {
450
+ await runBncrInboundReplyDispatch({
451
+ api,
452
+ channelId,
453
+ cfg,
454
+ parsed,
455
+ msgId,
456
+ peer,
457
+ rawBody,
458
+ storePath,
459
+ ctxPayload,
460
+ resolution,
461
+ replyRouteFact,
462
+ senderIdForContext,
463
+ senderDisplayName,
464
+ shouldDispatch,
465
+ silentHistoryFlush,
466
+ setInboundActivity,
467
+ scheduleSave,
468
+ enqueueFromReply,
469
+ });
470
+ scheduleSave();
471
+ return {
472
+ accountId,
473
+ sessionKey,
474
+ taskKey: extracted.taskKey ?? null,
475
+ msgId: msgId ?? null,
476
+ };
477
+ }
478
+
479
+ if (historyKey && historyShardQueue) {
480
+ let createdShardId: number | null = null;
481
+ try {
482
+ const shard = historyShardQueue.createHistoryShard({
483
+ historyKey,
484
+ accountId,
485
+ payloadJson: JSON.stringify({
486
+ version: 2,
487
+ historyKey,
488
+ accountId,
489
+ messageIds: snapshotMessageIds,
490
+ bufferKeys: cacheKey ? [cacheKey] : [],
491
+ dispatch: {
492
+ parsed,
493
+ msgId,
494
+ peer,
495
+ rawBody,
496
+ storePath,
497
+ ctxPayload,
498
+ resolution,
499
+ replyRouteFact,
500
+ senderIdForContext,
501
+ senderDisplayName,
502
+ shouldDispatch,
503
+ silentHistoryFlush,
504
+ },
505
+ }),
506
+ messageIds: snapshotMessageIds,
507
+ bufferKeys: cacheKey ? [cacheKey] : [],
508
+ });
509
+ if (shard !== null) {
510
+ createdShardId = shard.shardId;
511
+ // An existing shard already owns the shared snapshot. Keep stable
512
+ // delivery identity so this direct attempt and the worker retry
513
+ // carry the same ingest id instead of producing duplicates.
514
+ historyShardDeliveryId = `bncr-history-shard:${shard.shardId}`;
515
+ // The database shard owns the snapshot from this point. Removing it
516
+ // from the in-memory window immediately prevents a new trigger from
517
+ // double-sending the same messages while the shard is retried.
518
+ handle.phase('shard_created', {
519
+ snapshotMessageIds,
520
+ cacheKey,
521
+ });
522
+ removeBncrConversationHistoryMessageIds({
523
+ historyMap: conversationHistories,
524
+ historyKey,
525
+ messageIds: snapshotMessageIds,
526
+ });
527
+ removeBncrOutboundReplayMessageIds({
528
+ cache: outboundReplayCache,
529
+ parsed,
530
+ accountId,
531
+ messageIds: snapshotMessageIds,
532
+ });
533
+ if (historyShardQueue.markHistoryShardProcessing(shard.shardId, serialOwner) === false) {
534
+ throw new Error(
535
+ `history shard activation lost|key=${historyKey}|shard=${shard.shardId}`,
536
+ );
537
+ }
538
+ // The direct attempt now owns the claim and must finish cleanup even
539
+ // when it reused an existing row; otherwise the worker will retry the
540
+ // same snapshot after this upload has settled.
541
+ historyShardId = shard.shardId;
542
+ }
543
+ } catch (error) {
544
+ historyShardId = null;
545
+ emitBncrLogLine(
546
+ 'warn',
547
+ `[bncr] history shard create failed|key=${historyKey}|reason=${String(error)}`,
548
+ );
549
+ if (createdShardId !== null) {
550
+ // The shard row already exists and will be retried by the worker.
551
+ // Falling back to a direct upload here would duplicate the snapshot.
552
+ throw error;
553
+ }
554
+ }
555
+ }
556
+
557
+ handle.phase('upload_start', {
558
+ snapshotMessageIds,
559
+ cacheKey,
215
560
  });
216
- const knownMessageIds = new Set(
217
- pendingHistoryEntries
218
- .map((entry) => entry.messageId)
219
- .filter((messageId): messageId is string => Boolean(messageId)),
220
- );
221
- for (const entry of legacyOutboundEntries) {
222
- if (entry.messageId && knownMessageIds.has(entry.messageId)) continue;
223
- if (entry.messageId) knownMessageIds.add(entry.messageId);
224
- pendingHistoryEntries.push({
225
- sender: entry.sender,
226
- ...(entry.senderId ? { senderId: entry.senderId } : {}),
227
- body: entry.body,
228
- ...(typeof entry.timestamp === 'number' ? { timestamp: entry.timestamp } : {}),
229
- ...(entry.messageId ? { messageId: entry.messageId } : {}),
230
- role: 'assistant',
231
- ...(Array.isArray(entry.media)
232
- ? {
233
- media: entry.media.map((item) => ({
234
- path: item.path,
235
- contentType: item.contentType,
236
- kind: item.kind,
237
- messageId: item.messageId,
238
- })),
239
- }
240
- : {}),
561
+ try {
562
+ await runBncrHistoryShardWithLeaseRenewal({
563
+ shardId: historyShardId,
564
+ historyShardQueue,
565
+ owner: serialOwner,
566
+ task: () =>
567
+ runBncrInboundReplyDispatch({
568
+ api,
569
+ channelId,
570
+ cfg,
571
+ parsed,
572
+ msgId,
573
+ peer,
574
+ rawBody,
575
+ storePath,
576
+ ctxPayload,
577
+ resolution,
578
+ replyRouteFact,
579
+ senderIdForContext,
580
+ senderDisplayName,
581
+ shouldDispatch,
582
+ silentHistoryFlush,
583
+ deliveryId:
584
+ historyShardDeliveryId ??
585
+ (historyShardId !== null ? `bncr-history-shard:${historyShardId}` : undefined),
586
+ setInboundActivity,
587
+ scheduleSave,
588
+ enqueueFromReply,
589
+ }),
590
+ });
591
+ } catch (error) {
592
+ if (historyShardId !== null && historyShardQueue) {
593
+ try {
594
+ historyShardQueue.markHistoryShardFailed(historyShardId, error, serialOwner);
595
+ } catch (markError) {
596
+ emitBncrLogLine(
597
+ 'warn',
598
+ `[bncr] history shard failed mark failed|key=${historyKey}|shard=${historyShardId}|reason=${String(markError)}`,
599
+ );
600
+ }
601
+ }
602
+ throw error;
603
+ }
604
+ uploadSettled = true;
605
+ handle.phase('upload_end');
606
+ if (historyShardId !== null && historyShardQueue) {
607
+ try {
608
+ historyShardQueue.markHistoryShardCompleted(historyShardId, serialOwner);
609
+ } catch (error) {
610
+ emitBncrLogLine(
611
+ 'warn',
612
+ `[bncr] history shard complete mark failed|key=${historyKey}|shard=${historyShardId}|reason=${String(error)}`,
613
+ );
614
+ }
615
+ }
616
+ handle.phase('cache_delete_start');
617
+ const shouldClearLocalCache = historyShardId === null || uploadSettled;
618
+ if (historyKey && shouldClearLocalCache) {
619
+ removeBncrConversationHistoryMessageIds({
620
+ historyMap: conversationHistories,
621
+ historyKey,
622
+ messageIds: snapshotMessageIds,
623
+ });
624
+ }
625
+ if (shouldClearLocalCache) {
626
+ removeBncrOutboundReplayMessageIds({
627
+ cache: outboundReplayCache,
628
+ parsed,
629
+ accountId,
630
+ messageIds: snapshotMessageIds,
241
631
  });
242
632
  }
243
- pendingHistoryEntries.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
244
- if (resolvedHistoryLimit > 0 && pendingHistoryEntries.length > resolvedHistoryLimit) {
245
- const currentMessageId = String(msgId || '').trim();
246
- const currentEntry = currentMessageId
247
- ? pendingHistoryEntries.find((entry) => entry.messageId === currentMessageId)
248
- : undefined;
249
- pendingHistoryEntries = pendingHistoryEntries.slice(-resolvedHistoryLimit);
250
- if (currentEntry && !pendingHistoryEntries.includes(currentEntry)) {
251
- const withoutCurrent = pendingHistoryEntries.filter((entry) => entry !== currentEntry);
252
- pendingHistoryEntries = [
253
- ...withoutCurrent.slice(-(resolvedHistoryLimit - 1)),
254
- currentEntry,
255
- ];
633
+ handle.phase('cache_delete_done');
634
+ if (uploadSettled && historyShardId !== null && historyShardQueue) {
635
+ try {
636
+ historyShardQueue.completeHistoryShard(historyShardId, serialOwner);
637
+ } catch (error) {
638
+ emitBncrLogLine(
639
+ 'warn',
640
+ `[bncr] history shard complete failed|key=${historyKey}|shard=${historyShardId}|reason=${String(error)}`,
641
+ );
256
642
  }
257
643
  }
258
- }
644
+ scheduleSave();
259
645
 
260
- const ctxPayload = await buildBncrInboundTurnContext({
261
- api,
262
- cfg,
263
- channelId,
264
- parsed,
265
- msgId,
266
- peer,
267
- senderIdForContext,
268
- senderDisplayName,
269
- ownerAllowFrom,
270
- bridgeSenderId,
271
- bridgeSenderName,
272
- historyLimit: resolvedHistoryLimit,
273
- resolution,
274
- prepared,
275
- conversationHistories,
276
- shouldDispatch,
277
- silentHistoryFlush,
278
- pendingHistoryEntries,
646
+ return {
647
+ accountId,
648
+ sessionKey,
649
+ taskKey: extracted.taskKey ?? null,
650
+ msgId: msgId ?? null,
651
+ };
652
+ };
653
+
654
+ return runConversationHistorySerial(historyKey || '', processTurn, {
655
+ to: resolution.canonicalTo,
656
+ });
657
+ }
658
+
659
+ export async function dispatchBncrOutboundHistoryFlush(params: {
660
+ api: BncrInboundApi;
661
+ channelId: string;
662
+ cfg: BncrInboundConfig;
663
+ entry: OutboxEntry;
664
+ historyVersion?: number;
665
+ canonicalAgentId: string;
666
+ sceneRegistry: Map<string, BncrSceneRecord>;
667
+ conversationHistories: BncrConversationHistoryMap;
668
+ outboundReplayCache?: BncrOutboundReplayCache;
669
+ historyShardQueue?: BncrHistoryShardQueue;
670
+ defaultAdminAgentId: string;
671
+ defaultPublicAgentId: string;
672
+ now: () => number;
673
+ rememberSessionRoute: BncrRememberSessionRoute;
674
+ enqueueFromReply: BncrEnqueueFromReply;
675
+ setInboundActivity: (accountId: string, at: number) => void;
676
+ scheduleSave: () => void;
677
+ logger?: BncrInboundLogger;
678
+ }) {
679
+ const { entry, api, channelId, cfg, canonicalAgentId } = params;
680
+ const route = entry.route;
681
+ const parsed = parseBncrInboundParams({
682
+ accountId: entry.accountId,
683
+ platform: route.platform,
684
+ groupId: route.groupId || '0',
685
+ userId: route.userId || '0',
686
+ isGroup: (route.groupId || '0') !== '0',
687
+ sessionKey: entry.sessionKey,
688
+ type: 'text',
689
+ msg: 'bncr outbound conversation history flush',
690
+ ...(entry.messageId ? { msgId: `bncr-outbound-flush:${entry.messageId}` } : {}),
691
+ clientId: 'bncr-history-system',
279
692
  });
693
+ const historyKey = buildBncrConversationHistoryKey(parsed);
694
+ if (!historyKey) return;
695
+ const entries = params.conversationHistories.get(historyKey);
696
+ if (!entries || entries.length === 0) return;
697
+ const sceneForHistory = params.sceneRegistry.get(buildSceneKey(parsed));
698
+ if (sceneForHistory?.historyForce === false) return;
699
+ const sessionAgentId = parseStrictBncrSessionKey(entry.sessionKey)?.inputAgentId;
280
700
 
281
- await runBncrInboundReplyDispatch({
701
+ await dispatchBncrInbound({
282
702
  api,
283
703
  channelId,
284
704
  cfg,
285
705
  parsed,
286
- msgId,
287
- peer,
288
- rawBody,
289
- storePath,
290
- ctxPayload,
291
- resolution,
292
- replyRouteFact,
293
- senderIdForContext,
294
- senderDisplayName,
295
- shouldDispatch,
296
- silentHistoryFlush,
297
- setInboundActivity,
298
- scheduleSave,
299
- enqueueFromReply,
706
+ canonicalAgentId,
707
+ resolvedAgentId: sceneForHistory?.agentId || sessionAgentId,
708
+ shouldDispatch: true,
709
+ shouldAccumulate: false,
710
+ forceSilentHistoryFlush: true,
711
+ ...(params.historyVersion !== undefined
712
+ ? { forceSilentHistoryFlushVersion: params.historyVersion }
713
+ : {}),
714
+ ...(params.entry.messageId
715
+ ? { forceSilentHistoryFlushMessageId: String(params.entry.messageId).trim() }
716
+ : {}),
717
+ sceneRegistry: params.sceneRegistry,
718
+ conversationHistories: params.conversationHistories,
719
+ outboundReplayCache: params.outboundReplayCache,
720
+ historyShardQueue: params.historyShardQueue,
721
+ defaultAdminAgentId: params.defaultAdminAgentId,
722
+ defaultPublicAgentId: params.defaultPublicAgentId,
723
+ now: params.now,
724
+ rememberSessionRoute: params.rememberSessionRoute,
725
+ enqueueFromReply: params.enqueueFromReply,
726
+ setInboundActivity: params.setInboundActivity,
727
+ scheduleSave: params.scheduleSave,
728
+ logger: params.logger,
300
729
  });
301
-
302
- scheduleSave();
303
-
304
- return {
305
- accountId,
306
- sessionKey,
307
- taskKey: extracted.taskKey ?? null,
308
- msgId: msgId ?? null,
309
- };
310
730
  }