@xmoxmo/bncr 0.6.1 → 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.
Files changed (31) hide show
  1. package/README.md +65 -1
  2. package/package.json +1 -1
  3. package/scripts/check-pack.mjs +4 -1
  4. package/scripts/selfcheck.mjs +4 -1
  5. package/src/channel.ts +144 -5
  6. package/src/messaging/inbound/commands.ts +62 -26
  7. package/src/messaging/inbound/context-facts.ts +88 -58
  8. package/src/messaging/inbound/conversation-history-serial.ts +258 -0
  9. package/src/messaging/inbound/conversation-history.ts +450 -0
  10. package/src/messaging/inbound/dispatch.ts +568 -123
  11. package/src/messaging/inbound/history-shard-worker.ts +180 -0
  12. package/src/messaging/inbound/native-command.ts +89 -33
  13. package/src/messaging/inbound/outbound-replay-cache.ts +202 -17
  14. package/src/messaging/inbound/reply-dispatch.ts +7 -6
  15. package/src/messaging/inbound/scene-admin.ts +40 -20
  16. package/src/messaging/inbound/turn-context.ts +101 -37
  17. package/src/plugin/bridge-lifecycle.ts +8 -0
  18. package/src/plugin/bridge-outbox-facade.ts +15 -1
  19. package/src/plugin/channel-components.ts +5 -5
  20. package/src/plugin/channel-runtime-builders-delivery.ts +2 -2
  21. package/src/plugin/channel-runtime-constants.ts +2 -0
  22. package/src/plugin/channel-runtime-types.ts +9 -7
  23. package/src/plugin/inbound-acceptance.ts +9 -24
  24. package/src/plugin/inbound-handlers.ts +4 -4
  25. package/src/plugin/inbound-surface-handlers-group.ts +4 -2
  26. package/src/plugin/outbox-drain-post-push.ts +3 -0
  27. package/src/plugin/scene-registry.ts +4 -4
  28. package/src/plugin/sqlite-state.ts +2109 -0
  29. package/src/plugin/state-store.ts +731 -69
  30. package/src/plugin/state-transient-runtime-group.ts +17 -3
  31. package/src/messaging/inbound/group-history.ts +0 -264
@@ -1,7 +1,10 @@
1
- import { DEFAULT_GROUP_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,
@@ -10,6 +13,22 @@ import type {
10
13
  BncrInboundLogger,
11
14
  BncrRememberSessionRoute,
12
15
  } from './contracts.ts';
16
+ import {
17
+ type BncrConversationHistoryMap,
18
+ type BncrHistoryEntry,
19
+ buildBncrConversationHistoryKey,
20
+ readBncrPendingConversationHistorySnapshot,
21
+ readConversationHistoryVersion,
22
+ recordBncrPendingConversationMedia,
23
+ recordBncrPendingConversationText,
24
+ removeBncrConversationHistoryMessageIds,
25
+ resolveBncrConversationHistoryMessageId,
26
+ resolveBncrHistoryLimit,
27
+ } from './conversation-history.ts';
28
+ import {
29
+ type ConversationHistorySerialHandle,
30
+ runConversationHistorySerial,
31
+ } from './conversation-history-serial.ts';
13
32
  import {
14
33
  assertInboundMediaBase64Size,
15
34
  decodeInboundMediaBase64,
@@ -18,21 +37,16 @@ import {
18
37
  prepareBncrInboundDispatch,
19
38
  resolveBncrInboundConversation,
20
39
  } from './dispatch-prep.ts';
21
- import {
22
- type BncrGroupHistoryMap,
23
- type BncrHistoryEntry,
24
- buildBncrGroupHistoryKey,
25
- clearBncrPendingGroupHistory,
26
- readBncrPendingGroupHistorySnapshot,
27
- recordBncrPendingGroupMedia,
28
- recordBncrPendingGroupText,
29
- } from './group-history.ts';
40
+ import { runBncrHistoryShardWithLeaseRenewal } from './history-shard-worker.ts';
41
+ import { isBncrStopCommandText } from './native-command.ts';
30
42
  import {
31
43
  type BncrOutboundReplayCache,
32
44
  type BncrOutboundReplayEntry,
33
- clearBncrOutboundReplay,
45
+ buildBncrOutboundReplayKey,
34
46
  readBncrOutboundReplaySnapshot,
47
+ removeBncrOutboundReplayMessageIds,
35
48
  } from './outbound-replay-cache.ts';
49
+ import { parseBncrInboundParams } from './parse.ts';
36
50
  import { runBncrInboundReplyDispatch } from './reply-dispatch.ts';
37
51
  import { buildBncrInboundTurnContext } from './turn-context.ts';
38
52
 
@@ -51,10 +65,14 @@ export async function dispatchBncrInbound(params: {
51
65
  canonicalAgentId: string;
52
66
  shouldDispatch?: boolean;
53
67
  shouldAccumulate?: boolean;
68
+ forceSilentHistoryFlush?: boolean;
69
+ forceSilentHistoryFlushVersion?: number;
70
+ forceSilentHistoryFlushMessageId?: string;
54
71
  resolvedAgentId?: string;
55
72
  sceneRegistry: Map<string, BncrSceneRecord>;
56
- groupHistories: BncrGroupHistoryMap;
73
+ conversationHistories: BncrConversationHistoryMap;
57
74
  outboundReplayCache?: BncrOutboundReplayCache;
75
+ historyShardQueue?: BncrHistoryShardQueue;
58
76
  defaultAdminAgentId: string;
59
77
  defaultPublicAgentId: string;
60
78
  now: () => number;
@@ -72,9 +90,12 @@ export async function dispatchBncrInbound(params: {
72
90
  canonicalAgentId,
73
91
  shouldDispatch: initialShouldDispatch = true,
74
92
  shouldAccumulate = initialShouldDispatch,
93
+ forceSilentHistoryFlush = false,
94
+ forceSilentHistoryFlushVersion,
95
+ forceSilentHistoryFlushMessageId,
75
96
  resolvedAgentId,
76
97
  sceneRegistry = new Map(),
77
- groupHistories = new Map(),
98
+ conversationHistories = new Map(),
78
99
  outboundReplayCache = new Map(),
79
100
  defaultAdminAgentId,
80
101
  defaultPublicAgentId,
@@ -85,8 +106,10 @@ export async function dispatchBncrInbound(params: {
85
106
  scheduleSave,
86
107
  logger,
87
108
  } = params;
109
+ const historyShardQueue =
110
+ params.historyShardQueue ?? getBncrHistoryShardQueue(conversationHistories) ?? undefined;
88
111
  const { accountId, clientId, msgId, extracted, mimeType, peer } = parsed;
89
- let shouldDispatch = initialShouldDispatch;
112
+ let shouldDispatch = initialShouldDispatch || forceSilentHistoryFlush;
90
113
 
91
114
  const nativeCommand = await handleBncrNativeCommand({
92
115
  api,
@@ -144,142 +167,564 @@ export async function dispatchBncrInbound(params: {
144
167
  '[bncr] inbound missing clientId for chat identity; using route identity fallback',
145
168
  );
146
169
  }
147
- const sceneHistoryLimit =
148
- parsed.peer.kind === 'group'
149
- ? (sceneRegistry.get(buildSceneKey(parsed))?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT)
150
- : DEFAULT_GROUP_HISTORY_LIMIT;
151
- const resolvedHistoryLimit =
152
- typeof sceneHistoryLimit === 'number' &&
153
- Number.isFinite(sceneHistoryLimit) &&
154
- sceneHistoryLimit >= 0
155
- ? Math.floor(sceneHistoryLimit)
156
- : DEFAULT_GROUP_HISTORY_LIMIT;
157
-
158
- let silentHistoryFlush = false;
159
- let pendingHistoryEntries: BncrHistoryEntry[] = [];
160
- if (!shouldDispatch && shouldAccumulate) {
161
- const historyKey = buildBncrGroupHistoryKey(parsed);
162
- // Record the message first so overflow context includes it
163
- recordBncrPendingGroupText({
164
- historyMap: groupHistories,
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,
165
183
  parsed,
184
+ msgId,
185
+ peer,
186
+ senderIdForContext,
166
187
  senderDisplayName,
167
- senderId: senderIdForContext,
168
- bodyText: rawBody,
188
+ ownerAllowFrom,
189
+ bridgeSenderId,
190
+ bridgeSenderName,
169
191
  historyLimit: resolvedHistoryLimit,
192
+ resolution,
193
+ prepared,
194
+ conversationHistories: new Map(),
195
+ shouldDispatch: true,
196
+ silentHistoryFlush: false,
197
+ pendingHistoryEntries: [],
170
198
  });
171
- await recordBncrPendingGroupMedia({
172
- historyMap: groupHistories,
199
+ await runBncrInboundReplyDispatch({
200
+ api,
201
+ channelId,
202
+ cfg,
173
203
  parsed,
204
+ msgId,
205
+ peer,
206
+ rawBody,
207
+ storePath,
208
+ ctxPayload,
209
+ resolution,
210
+ replyRouteFact,
211
+ senderIdForContext,
174
212
  senderDisplayName,
175
- senderId: senderIdForContext,
176
- bodyText: rawBody,
177
- mediaItems,
178
- mediaContentType: mediaContentType || mimeType,
179
- historyLimit: resolvedHistoryLimit,
213
+ shouldDispatch: true,
214
+ silentHistoryFlush: false,
215
+ setInboundActivity,
216
+ scheduleSave,
217
+ enqueueFromReply,
180
218
  });
181
- if (historyKey && parsed.peer.kind === 'group') {
182
- const entries = groupHistories.get(historyKey);
183
- if (entries && resolvedHistoryLimit > 0 && entries.length >= resolvedHistoryLimit) {
184
- const sceneForHistory = sceneRegistry.get(buildSceneKey(parsed));
185
- const historyForce = sceneForHistory?.historyForce !== false;
186
- if (historyForce) {
187
- pendingHistoryEntries = readBncrPendingGroupHistorySnapshot({
188
- historyMap: groupHistories,
189
- parsed,
190
- historyLimit: resolvedHistoryLimit,
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
+ }
281
+ }
282
+ }
283
+ }
284
+
285
+ if (historyKey && historyShardQueue?.reconcileHistoryMemory) {
286
+ await historyShardQueue.reconcileHistoryMemory(historyKey);
287
+ }
288
+
289
+ if (shouldDispatch) {
290
+ pendingHistoryEntries = readBncrPendingConversationHistorySnapshot({
291
+ historyMap: conversationHistories,
292
+ parsed,
293
+ historyLimit: resolvedHistoryLimit,
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,
191
395
  });
192
- clearBncrPendingGroupHistory({
193
- historyMap: groupHistories,
396
+ }
397
+ if (shouldClearLocalCache) {
398
+ removeBncrOutboundReplayMessageIds({
399
+ cache: outboundReplayCache,
194
400
  parsed,
195
- historyLimit: resolvedHistoryLimit,
401
+ accountId,
402
+ messageIds: snapshotMessageIds,
196
403
  });
197
- shouldDispatch = true;
198
- silentHistoryFlush = true;
199
404
  }
200
- }
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
+ });
201
420
  }
202
- }
203
-
204
- if (shouldDispatch && !silentHistoryFlush) {
205
- pendingHistoryEntries = readBncrPendingGroupHistorySnapshot({
206
- historyMap: groupHistories,
421
+ const ctxPayload = await buildBncrInboundTurnContext({
422
+ api,
423
+ cfg,
424
+ channelId,
207
425
  parsed,
426
+ msgId,
427
+ peer,
428
+ senderIdForContext,
429
+ senderDisplayName,
430
+ ownerAllowFrom,
431
+ bridgeSenderId,
432
+ bridgeSenderName,
208
433
  historyLimit: resolvedHistoryLimit,
434
+ resolution,
435
+ prepared,
436
+ conversationHistories,
437
+ shouldDispatch,
438
+ silentHistoryFlush,
439
+ pendingHistoryEntries,
209
440
  });
210
- if (pendingHistoryEntries.length > 0) {
211
- clearBncrPendingGroupHistory({
212
- historyMap: groupHistories,
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,
213
454
  parsed,
214
- historyLimit: resolvedHistoryLimit,
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,
215
469
  });
470
+ scheduleSave();
471
+ return {
472
+ accountId,
473
+ sessionKey,
474
+ taskKey: extracted.taskKey ?? null,
475
+ msgId: msgId ?? null,
476
+ };
216
477
  }
217
- }
218
478
 
219
- let outboundReplayEntries: BncrOutboundReplayEntry[] = [];
220
- if (shouldDispatch && !silentHistoryFlush) {
221
- outboundReplayEntries = readBncrOutboundReplaySnapshot({
222
- cache: outboundReplayCache,
223
- parsed,
224
- accountId,
225
- excludeMessageId: msgId,
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,
226
560
  });
227
- clearBncrOutboundReplay({
228
- cache: outboundReplayCache,
229
- parsed,
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,
631
+ });
632
+ }
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
+ );
642
+ }
643
+ }
644
+ scheduleSave();
645
+
646
+ return {
230
647
  accountId,
231
- });
232
- }
648
+ sessionKey,
649
+ taskKey: extracted.taskKey ?? null,
650
+ msgId: msgId ?? null,
651
+ };
652
+ };
233
653
 
234
- const ctxPayload = await buildBncrInboundTurnContext({
235
- api,
236
- cfg,
237
- channelId,
238
- parsed,
239
- msgId,
240
- peer,
241
- senderIdForContext,
242
- senderDisplayName,
243
- ownerAllowFrom,
244
- bridgeSenderId,
245
- bridgeSenderName,
246
- historyLimit: resolvedHistoryLimit,
247
- resolution,
248
- prepared,
249
- groupHistories,
250
- shouldDispatch,
251
- silentHistoryFlush,
252
- pendingHistoryEntries,
253
- outboundReplayEntries,
654
+ return runConversationHistorySerial(historyKey || '', processTurn, {
655
+ to: resolution.canonicalTo,
254
656
  });
657
+ }
255
658
 
256
- await runBncrInboundReplyDispatch({
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',
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;
700
+
701
+ await dispatchBncrInbound({
257
702
  api,
258
703
  channelId,
259
704
  cfg,
260
705
  parsed,
261
- msgId,
262
- peer,
263
- rawBody,
264
- storePath,
265
- ctxPayload,
266
- resolution,
267
- replyRouteFact,
268
- senderIdForContext,
269
- senderDisplayName,
270
- shouldDispatch,
271
- silentHistoryFlush,
272
- setInboundActivity,
273
- scheduleSave,
274
- 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,
275
729
  });
276
-
277
- scheduleSave();
278
-
279
- return {
280
- accountId,
281
- sessionKey,
282
- taskKey: extracted.taskKey ?? null,
283
- msgId: msgId ?? null,
284
- };
285
730
  }