@xmoxmo/bncr 0.3.3 → 0.3.5

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 (61) hide show
  1. package/dist/index.js +7 -3
  2. package/index.ts +11 -10
  3. package/openclaw.plugin.json +21 -0
  4. package/package.json +4 -4
  5. package/scripts/check-pack.mjs +112 -22
  6. package/scripts/check-register-drift.mjs +91 -65
  7. package/scripts/selfcheck.mjs +79 -3
  8. package/src/channel.ts +549 -810
  9. package/src/core/accounts.ts +1 -1
  10. package/src/core/connection-capability.ts +2 -2
  11. package/src/core/connection-reachability.ts +112 -1
  12. package/src/core/dead-letter-diagnostics.ts +91 -0
  13. package/src/core/diagnostic-counters.ts +61 -0
  14. package/src/core/diagnostics.ts +9 -5
  15. package/src/core/downlink-health.ts +15 -10
  16. package/src/core/extended-diagnostics.ts +4 -0
  17. package/src/core/file-transfer-payloads.ts +1 -4
  18. package/src/core/logging.ts +98 -0
  19. package/src/core/outbox-entry-builders.ts +15 -2
  20. package/src/core/outbox-file-transfer-bookkeeping.ts +1 -1
  21. package/src/core/outbox-file-transfer-failure.ts +2 -5
  22. package/src/core/outbox-file-transfer-success.ts +1 -4
  23. package/src/core/outbox-text-push-failure.ts +2 -4
  24. package/src/core/outbox-text-push-success.ts +1 -1
  25. package/src/core/persisted-outbox-entry.ts +53 -0
  26. package/src/core/probe.ts +33 -13
  27. package/src/core/register-trace.ts +48 -0
  28. package/src/core/status-meta.ts +77 -0
  29. package/src/core/status.ts +50 -57
  30. package/src/messaging/inbound/commands.ts +42 -94
  31. package/src/messaging/inbound/dispatch.ts +25 -54
  32. package/src/messaging/inbound/last-route.ts +46 -0
  33. package/src/messaging/inbound/native-command.ts +49 -0
  34. package/src/messaging/inbound/native-reply-delivery.ts +43 -0
  35. package/src/messaging/inbound/parse.ts +3 -3
  36. package/src/messaging/inbound/runtime-compat.ts +8 -2
  37. package/src/messaging/outbound/build-send-action.ts +1 -2
  38. package/src/messaging/outbound/diagnostics.ts +221 -2
  39. package/src/messaging/outbound/durable-message-adapter.ts +15 -5
  40. package/src/messaging/outbound/durable-queue-adapter.ts +3 -1
  41. package/src/messaging/outbound/media.ts +2 -1
  42. package/src/messaging/outbound/queue-selectors.ts +19 -6
  43. package/src/messaging/outbound/reasons.ts +2 -0
  44. package/src/messaging/outbound/reply-enqueue.ts +29 -2
  45. package/src/messaging/outbound/reply-target-policy.ts +4 -1
  46. package/src/messaging/outbound/retry-policy.ts +16 -8
  47. package/src/messaging/outbound/send-params.ts +56 -0
  48. package/src/messaging/outbound/session-route.ts +1 -1
  49. package/src/openclaw/reply-runtime.ts +4 -5
  50. package/src/openclaw/routing-runtime.ts +0 -1
  51. package/src/openclaw/runtime-surface.ts +29 -0
  52. package/src/openclaw/sdk-helpers.ts +4 -1
  53. package/src/plugin/gateway-methods.ts +2 -0
  54. package/src/plugin/messaging.ts +2 -9
  55. package/src/plugin/status.ts +15 -5
  56. package/src/runtime/outbound-ack-timeout.ts +73 -0
  57. package/src/runtime/outbound-flags.ts +1 -1
  58. package/src/runtime/outbox-transitions.ts +4 -4
  59. package/src/runtime/register-trace-runtime.ts +102 -0
  60. package/src/runtime/status-snapshots.ts +10 -4
  61. package/src/runtime/status-worker.ts +78 -13
package/src/channel.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import fs from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import type {
5
4
  GatewayRequestHandlerOptions,
@@ -24,9 +23,23 @@ import {
24
23
  getRevalidatedAttemptReason,
25
24
  hasAlternativeLiveConnection as hasAlternativeLiveConnectionFromRuntime,
26
25
  hasRecentInboundReachability as hasRecentInboundReachabilityFromRuntime,
26
+ isEligibleOutboundPushConnection,
27
27
  isRecentlyReachableConn as isRecentlyReachableConnFromRuntime,
28
28
  resolveRecentInboundConnIds as resolveRecentInboundConnIdsFromRuntime,
29
+ selectOrderedOutboundPushConnections,
29
30
  } from './core/connection-reachability.ts';
31
+ import {
32
+ buildDeadLetterDiagnostics as buildDeadLetterDiagnosticsFromRuntime,
33
+ formatDeadLetterTopReasons,
34
+ parseDeadLetterLimit,
35
+ parseDeadLetterOffset,
36
+ parseDeadLetterOlderThan,
37
+ summarizeDeadLetterEntry,
38
+ } from './core/dead-letter-diagnostics.ts';
39
+ import {
40
+ countInvalidOutboxSessionKeys as countInvalidOutboxSessionKeysFromRuntime,
41
+ countLegacyAccountResidue as countLegacyAccountResidueFromRuntime,
42
+ } from './core/diagnostic-counters.ts';
30
43
  import { buildDiagnosticsPayload } from './core/diagnostics.ts';
31
44
  import { buildDownlinkHealth as buildDownlinkHealthFromRuntime } from './core/downlink-health.ts';
32
45
  import { buildExtendedDiagnostics as buildExtendedDiagnosticsFromRuntime } from './core/extended-diagnostics.ts';
@@ -41,7 +54,12 @@ import {
41
54
  matchesTransferOwner as matchesTransferOwnerFromRuntime,
42
55
  observeLeaseState,
43
56
  } from './core/lease-state.ts';
44
- import { emitBncrLog, emitBncrLogLine } from './core/logging.ts';
57
+ import {
58
+ buildBncrDebugJsonMessage,
59
+ emitBncrLog,
60
+ emitBncrLogLine,
61
+ summarizeBncrTextPreview,
62
+ } from './core/logging.ts';
45
63
  import { buildOutboxEnqueueDebugInfo } from './core/outbox-enqueue.ts';
46
64
  import {
47
65
  buildFileTransferOutboxEntry as buildFileTransferOutboxEntryFromRuntime,
@@ -76,12 +94,11 @@ import {
76
94
  buildTextPushRouteSelectArgs,
77
95
  buildTextPushSuccessArgs,
78
96
  } from './core/outbox-text-push-success.ts';
97
+ import { normalizePersistedOutboxEntry as normalizePersistedOutboxEntryFromRuntime } from './core/persisted-outbox-entry.ts';
79
98
  import { resolveBncrChannelPolicy, resolveBncrConfigWarnings } from './core/policy.ts';
80
99
  import {
81
- appendBoundedRegisterTrace,
82
- buildRegisterDriftSnapshot,
83
- buildRegisterTraceEntry,
84
- buildRegisterTraceSummary as buildRegisterTraceSummaryFromEntries,
100
+ dumpRegisterDriftSnapshot,
101
+ normalizeRegisterDriftSnapshot,
85
102
  } from './core/register-trace.ts';
86
103
  import {
87
104
  buildAccountRuntimeSnapshot,
@@ -92,64 +109,57 @@ import {
92
109
  import {
93
110
  buildCanonicalBncrSessionKey,
94
111
  formatDisplayScope,
95
- isLowerHex,
96
112
  normalizeInboundSessionKey,
97
113
  normalizeStoredSessionKey,
98
114
  parseRouteFromDisplayScope,
99
- parseRouteFromHexScope,
100
- parseRouteFromScope,
101
115
  parseRouteLike,
102
116
  parseStrictBncrSessionKey,
103
117
  routeKey,
104
- routeScopeToHex,
105
118
  withTaskSessionKey,
106
119
  } from './core/targets.ts';
107
120
  import type { BncrConnection, BncrRoute, OutboxEntry } from './core/types.ts';
108
121
  import { dispatchBncrInbound } from './messaging/inbound/dispatch.ts';
109
122
  import { checkBncrMessageGate } from './messaging/inbound/gate.ts';
110
123
  import { parseBncrInboundParams } from './messaging/inbound/parse.ts';
111
- import {
112
- deleteBncrMessageAction,
113
- editBncrMessageAction,
114
- reactBncrMessageAction,
115
- sendBncrReplyAction,
116
- } from './messaging/outbound/actions.ts';
117
124
  import {
118
125
  buildEnqueueFromReplyDebugInfo,
126
+ buildExtendedOutboundDiagnostics,
119
127
  buildFlushDebugInfo,
120
128
  buildOutboxAckDebugInfo,
121
129
  buildOutboxDrainSkipDebugInfo,
122
130
  buildOutboxDrainStuckDebugInfo,
123
131
  buildOutboxPushOkDebugInfo,
124
132
  buildOutboxPushSkipDebugInfo,
133
+ buildOutboxQueueDiagnostics,
125
134
  buildOutboxRouteSelectDebugInfo,
126
135
  buildOutboxScheduleDebugInfo,
127
136
  buildPushFailureDebugInfo,
128
- buildReplyMediaFallbackDebugInfo,
129
137
  buildRetryRerouteDebugInfo,
130
138
  } from './messaging/outbound/diagnostics.ts';
131
- import {
132
- buildBncrMediaOutboundFrame,
133
- resolveBncrOutboundMessageType,
134
- } from './messaging/outbound/media.ts';
139
+ import { buildBncrMediaOutboundFrame } from './messaging/outbound/media.ts';
140
+ import { normalizeBncrSendParams } from './messaging/outbound/send-params.ts';
135
141
  import {
136
142
  getOpenClawRuntimeConfig,
137
143
  getOpenClawRuntimeConfigOrDefault,
138
144
  } from './openclaw/config-runtime.ts';
139
145
  import {
140
- type OpenClawLoadedMedia,
141
146
  loadOpenClawWebMedia,
147
+ type OpenClawLoadedMedia,
142
148
  saveOpenClawChannelMediaBuffer,
143
149
  } from './openclaw/media-runtime.ts';
144
150
  import { resolveOpenClawAgentRoute } from './openclaw/routing-runtime.ts';
151
+ import { buildOpenClawChannelRuntimeSurfaceDiagnostics } from './openclaw/runtime-surface.ts';
145
152
  import {
146
153
  extractOpenClawToolSend,
147
154
  openClawJsonResult,
148
- readOpenClawBooleanParam,
149
155
  readOpenClawJsonFileWithFallback,
150
- readOpenClawStringParam,
151
156
  writeOpenClawJsonFileAtomically,
152
157
  } from './openclaw/sdk-helpers.ts';
158
+ import type { RegisterTraceRuntimeState } from './runtime/register-trace-runtime.ts';
159
+ import {
160
+ buildRegisterTraceRuntimeSummary,
161
+ noteRegisterTraceRuntime,
162
+ } from './runtime/register-trace-runtime.ts';
153
163
 
154
164
  function buildInboundAcceptedLifecycleDebugInfo(args: {
155
165
  stage: 'accepted';
@@ -283,10 +293,11 @@ function buildInboundResponsePayload(
283
293
  };
284
294
  }
285
295
  }
296
+
286
297
  import { buildBncrDurableQueuedResult } from './messaging/outbound/durable-queue-adapter.ts';
287
298
  import {
288
- type MediaDedupeCacheEntry,
289
299
  buildMediaTextFallback,
300
+ type MediaDedupeCacheEntry,
290
301
  normalizeMessageText,
291
302
  normalizeReplyToId,
292
303
  } from './messaging/outbound/media-dedupe.ts';
@@ -310,18 +321,17 @@ import {
310
321
  OUTBOUND_TERMINAL_REASON,
311
322
  } from './messaging/outbound/reasons.ts';
312
323
  import {
313
- type NormalizedReplyPayload,
314
- type ReplyMediaEntriesParams,
315
- type ReplyMediaFileTransferParams,
316
- type ReplyPayloadInput,
317
- buildReplyTextOutboxEntry,
318
324
  enqueueNormalizedReplyPayload,
319
325
  enqueueReplyMediaFallbackTextEntry,
320
326
  enqueueReplyMediaFileTransferEntry,
321
327
  enqueueReplyTextEntry,
322
328
  enqueueSingleReplyMediaEntry,
323
329
  hasReplyMediaEntries,
330
+ type NormalizedReplyPayload,
324
331
  normalizeReplyPayload,
332
+ type OutboundReplyTargetPolicy,
333
+ type ReplyMediaEntriesParams,
334
+ type ReplyPayloadInput,
325
335
  } from './messaging/outbound/reply-enqueue.ts';
326
336
  import {
327
337
  computePushFailureDecision,
@@ -339,14 +349,11 @@ import { BNCR_CHANNEL_META } from './plugin/meta.ts';
339
349
  import { createBncrOutboundRuntime } from './plugin/outbound.ts';
340
350
  import { BNCR_SETUP_SURFACE } from './plugin/setup.ts';
341
351
  import { createBncrStatusSurface } from './plugin/status.ts';
352
+ import { shouldEmitDedupLog as shouldEmitDedupLogFromRuntime } from './runtime/log-dedupe.ts';
342
353
  import {
343
- pruneLogDedupeState as pruneLogDedupeStateFromRuntime,
344
- shouldEmitDedupLog as shouldEmitDedupLogFromRuntime,
345
- } from './runtime/log-dedupe.ts';
346
- import {
354
+ buildBncrRuntimeAckObservability,
347
355
  buildBncrRuntimeAckStrategy,
348
- computeBncrRecommendedAckTimeoutMs,
349
- computeBncrRecommendedAckTimeoutReason,
356
+ resolveBncrRuntimeAckTimeoutDecision,
350
357
  } from './runtime/outbound-ack-timeout.ts';
351
358
  import {
352
359
  buildBncrRuntimeFlags,
@@ -365,10 +372,10 @@ import { buildRuntimeStatusSnapshots } from './runtime/status-snapshots.ts';
365
372
  import {
366
373
  type ChannelAccountWorkerHandle,
367
374
  clearAllBncrStatusWorkers,
368
- clearBncrStatusWorker,
369
375
  startBncrStatusWorker,
370
376
  stopBncrStatusWorker,
371
377
  } from './runtime/status-worker.ts';
378
+
372
379
  const BRIDGE_VERSION = 2;
373
380
  const BNCR_PUSH_EVENT = 'plugin.bncr.push';
374
381
  const BNCR_FILE_INIT_EVENT = 'plugin.bncr.file.init';
@@ -387,6 +394,7 @@ const PUSH_DRAIN_ACCOUNT_TIME_BUDGET_MS = 2_000;
387
394
  const PUSH_DRAIN_EXCEPTION_RETRY_LIMIT = 3;
388
395
  const PUSH_DRAIN_EXCEPTION_RETRY_DELAY_MS = 1_000;
389
396
  const PUSH_DRAIN_STUCK_WARN_MS = 30_000;
397
+ const PRE_PUSH_GUARD_RETRY_DELAY_MS = 1_000;
390
398
  const PUSH_ACK_TIMEOUT_MS = 30_000;
391
399
  const ADAPTIVE_ACK_TIMEOUT_DEFAULT_ENABLED = true;
392
400
  const RECOMMENDED_ACK_TIMEOUT_MIN_MS = PUSH_ACK_TIMEOUT_MS;
@@ -402,7 +410,6 @@ const FILE_CHUNK_SIZE = 256 * 1024; // 256KB
402
410
  const INBOUND_FILE_TRANSFER_MAX_BYTES = 50 * 1024 * 1024;
403
411
  const INBOUND_FILE_TRANSFER_MAX_CHUNKS =
404
412
  Math.ceil(INBOUND_FILE_TRANSFER_MAX_BYTES / FILE_CHUNK_SIZE) + 1;
405
- const FILE_CHUNK_RETRY = 3;
406
413
  const FILE_ACK_TIMEOUT_MS = 30_000;
407
414
  const FILE_TRANSFER_ACK_TTL_MS = 30_000;
408
415
  const MAX_EARLY_FILE_ACKS = 1000;
@@ -462,8 +469,6 @@ type FileAckPayloadState = {
462
469
  at: number;
463
470
  };
464
471
 
465
- type ChatType = 'direct' | 'group' | (string & {});
466
-
467
472
  type ChannelMessageActionAdapter = {
468
473
  describeMessageTool: (ctx: { cfg: any }) => { actions: string[]; capabilities: unknown[] } | null;
469
474
  supportsAction: (ctx: { action: string }) => boolean;
@@ -517,56 +522,6 @@ type PersistedState = {
517
522
  } | null;
518
523
  };
519
524
 
520
- type NormalizedBncrSendParams = {
521
- to: string;
522
- accountId: string;
523
- message: string;
524
- caption: string;
525
- mediaUrl?: string;
526
- asVoice: boolean;
527
- audioAsVoice: boolean;
528
- };
529
-
530
- function normalizeBncrSendParams(input: {
531
- params: unknown;
532
- accountId: string;
533
- }): NormalizedBncrSendParams {
534
- const paramsObj = isPlainObject(input.params) ? input.params : {};
535
- const to = readOpenClawStringParam(paramsObj, 'to', { required: true });
536
- const resolvedAccountId = normalizeAccountId(
537
- readOpenClawStringParam(paramsObj, 'accountId') ?? input.accountId,
538
- );
539
-
540
- const message = readOpenClawStringParam(paramsObj, 'message', { allowEmpty: true }) ?? '';
541
- const caption = readOpenClawStringParam(paramsObj, 'caption', { allowEmpty: true }) ?? '';
542
- const mediaUrl =
543
- readOpenClawStringParam(paramsObj, 'media', { trim: false }) ??
544
- readOpenClawStringParam(paramsObj, 'path', { trim: false }) ??
545
- readOpenClawStringParam(paramsObj, 'filePath', { trim: false }) ??
546
- readOpenClawStringParam(paramsObj, 'mediaUrl', { trim: false });
547
- const asVoice = readOpenClawBooleanParam(paramsObj, 'asVoice') ?? false;
548
- const audioAsVoice = readOpenClawBooleanParam(paramsObj, 'audioAsVoice') ?? false;
549
-
550
- if (asVoice && !mediaUrl) throw new Error('send voice requires media path');
551
-
552
- const normalizedMessage = mediaUrl ? '' : message || caption || '';
553
- const normalizedCaption = mediaUrl ? caption || message || '' : '';
554
-
555
- if (!normalizedMessage.trim() && !normalizedCaption.trim() && !mediaUrl) {
556
- throw new Error('send requires message or media');
557
- }
558
-
559
- return {
560
- to,
561
- accountId: resolvedAccountId,
562
- message: normalizedMessage,
563
- caption: normalizedCaption,
564
- mediaUrl: mediaUrl || undefined,
565
- asVoice,
566
- audioAsVoice,
567
- };
568
- }
569
-
570
525
  function now() {
571
526
  return Date.now();
572
527
  }
@@ -582,12 +537,6 @@ function finiteNumberOr(value: unknown, fallback: number): number {
582
537
  return Number.isFinite(n) ? n : fallback;
583
538
  }
584
539
 
585
- function optionalFiniteNumber(value: unknown): number | undefined {
586
- if (value == null || value === '') return undefined;
587
- const n = Number(value);
588
- return Number.isFinite(n) ? n : undefined;
589
- }
590
-
591
540
  function finiteNonNegativeNumberOrNull(value: unknown): number | null {
592
541
  const n = Number(value);
593
542
  return Number.isFinite(n) && n >= 0 ? n : null;
@@ -770,8 +719,6 @@ class BncrBridgeRuntime {
770
719
  private channelAccountWorkers = new Map<string, ChannelAccountWorkerHandle>();
771
720
  private logDedupeState = new Map<string, { at: number; sig: string }>();
772
721
  private canonicalAgentId: string | null = null;
773
- private canonicalAgentSource: 'startup' | 'runtime' | 'fallback-main' | null = null;
774
- private canonicalAgentResolvedAt: number | null = null;
775
722
 
776
723
  // 内置健康/回归计数(替代独立脚本)
777
724
  private startedAt = now();
@@ -787,6 +734,13 @@ class BncrBridgeRuntime {
787
734
  private pushDrainRunningSinceByAccount = new Map<string, number>();
788
735
  private pushDrainStuckWarnedAtByAccount = new Map<string, number>();
789
736
  private pushDrainExceptionRetryCount = 0;
737
+ private lastGatewayContextAt: number | null = null;
738
+ private outboundEnqueueCountByAccount = new Map<string, number>();
739
+ private lastOutboundEnqueueAtByAccount = new Map<string, number>();
740
+ private prePushGuardSkipCountByAccount = new Map<string, number>();
741
+ private lastPrePushGuardSkipAtByAccount = new Map<string, number>();
742
+ private lastPrePushGuardSkipReasonByAccount = new Map<string, string>();
743
+ private deadLetterSinceStartByAccount = new Map<string, number>();
790
744
  private messageAckWaiters = new Map<
791
745
  // Refactor boundary note (message ACK runtime):
792
746
  // These waiters are part of the outbound message-ack lifecycle, not just a utility map.
@@ -849,39 +803,13 @@ class BncrBridgeRuntime {
849
803
  emitBncrLog('error', scope, message, options, () => this.isDebugEnabled());
850
804
  }
851
805
 
852
- private buildDebugJsonMessage(event: string, payload: Record<string, unknown>) {
853
- return `${event} ${JSON.stringify(payload)}`;
854
- }
855
-
856
806
  private logInfoJson(
857
807
  scope: string | undefined,
858
808
  event: string,
859
809
  payload: Record<string, unknown>,
860
810
  options?: { debugOnly?: boolean },
861
811
  ) {
862
- this.logInfo(scope, this.buildDebugJsonMessage(event, payload), options);
863
- }
864
-
865
- private logWarnJson(
866
- scope: string | undefined,
867
- event: string,
868
- payload: Record<string, unknown>,
869
- options?: { debugOnly?: boolean },
870
- ) {
871
- this.logWarn(scope, this.buildDebugJsonMessage(event, payload), options);
872
- }
873
-
874
- private logErrorJson(
875
- scope: string | undefined,
876
- event: string,
877
- payload: Record<string, unknown>,
878
- options?: { debugOnly?: boolean },
879
- ) {
880
- this.logError(scope, this.buildDebugJsonMessage(event, payload), options);
881
- }
882
-
883
- private pruneLogDedupeState(currentTime = now()) {
884
- pruneLogDedupeStateFromRuntime(this.logDedupeState, currentTime);
812
+ this.logInfo(scope, buildBncrDebugJsonMessage(event, payload), options);
885
813
  }
886
814
 
887
815
  private shouldEmitDedupLog(key: string, sig: string, windowMs = 5 * 60 * 1000) {
@@ -903,24 +831,6 @@ class BncrBridgeRuntime {
903
831
  this.logInfo(scope, message, { debugOnly: options.debugOnly });
904
832
  }
905
833
 
906
- private logWarnDedup(
907
- scope: string | undefined,
908
- message: string,
909
- options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
910
- ) {
911
- if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
912
- this.logWarn(scope, message, { debugOnly: options.debugOnly });
913
- }
914
-
915
- private logErrorDedup(
916
- scope: string | undefined,
917
- message: string,
918
- options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
919
- ) {
920
- if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
921
- this.logError(scope, message, { debugOnly: options.debugOnly });
922
- }
923
-
924
834
  private logInfoDedupJson(
925
835
  scope: string | undefined,
926
836
  event: string,
@@ -931,33 +841,8 @@ class BncrBridgeRuntime {
931
841
  this.logInfoJson(scope, event, payload, { debugOnly: options.debugOnly });
932
842
  }
933
843
 
934
- private logWarnDedupJson(
935
- scope: string | undefined,
936
- event: string,
937
- payload: Record<string, unknown>,
938
- options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
939
- ) {
940
- if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
941
- this.logWarnJson(scope, event, payload, { debugOnly: options.debugOnly });
942
- }
943
-
944
- private logErrorDedupJson(
945
- scope: string | undefined,
946
- event: string,
947
- payload: Record<string, unknown>,
948
- options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
949
- ) {
950
- if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
951
- this.logErrorJson(scope, event, payload, { debugOnly: options.debugOnly });
952
- }
953
-
954
844
  private summarizeTextPreview(raw: string, limit = 8) {
955
- const compact = asString(raw || '')
956
- .replace(/\s+/g, ' ')
957
- .trim();
958
- if (!compact) return '-';
959
- const chars = Array.from(compact);
960
- return chars.length > limit ? `${chars.slice(0, Math.max(1, limit)).join('')}…` : compact;
845
+ return summarizeBncrTextPreview(raw, limit);
961
846
  }
962
847
 
963
848
  private summarizeScope(route: BncrRoute) {
@@ -1026,33 +911,43 @@ class BncrBridgeRuntime {
1026
911
  };
1027
912
  }
1028
913
 
1029
- private clearChannelAccountWorker(accountId: string, reason: string) {
1030
- return clearBncrStatusWorker(this.buildStatusWorkerRuntime(), accountId, reason);
1031
- }
1032
-
1033
914
  private clearAllChannelAccountWorkers(reason: string) {
1034
915
  clearAllBncrStatusWorkers(this.buildStatusWorkerRuntime(), reason);
1035
916
  }
1036
917
 
1037
- private captureDriftSnapshot(
1038
- summary: ReturnType<BncrBridgeRuntime['buildRegisterTraceSummary']>,
1039
- ) {
1040
- this.lastDriftSnapshot = buildRegisterDriftSnapshot({
1041
- capturedAt: now(),
918
+ private getRegisterTraceRuntimeState(): RegisterTraceRuntimeState {
919
+ return {
1042
920
  registerCount: this.registerCount,
1043
921
  apiGeneration: this.apiGeneration,
1044
- summary,
1045
- apiInstanceId: this.lastApiInstanceId,
1046
- registryFingerprint: this.lastRegistryFingerprint,
1047
- traceRecent: this.registerTraceRecent,
1048
- });
1049
- this.scheduleSave();
922
+ firstRegisterAt: this.firstRegisterAt,
923
+ lastRegisterAt: this.lastRegisterAt,
924
+ lastApiRebindAt: this.lastApiRebindAt,
925
+ pluginSource: this.pluginSource,
926
+ pluginVersion: this.pluginVersion,
927
+ lastApiInstanceId: this.lastApiInstanceId,
928
+ lastRegistryFingerprint: this.lastRegistryFingerprint,
929
+ lastDriftSnapshot: this.lastDriftSnapshot,
930
+ registerTraceRecent: this.registerTraceRecent,
931
+ };
932
+ }
933
+
934
+ private applyRegisterTraceRuntimeState(state: RegisterTraceRuntimeState) {
935
+ this.registerCount = state.registerCount;
936
+ this.apiGeneration = state.apiGeneration;
937
+ this.firstRegisterAt = state.firstRegisterAt;
938
+ this.lastRegisterAt = state.lastRegisterAt;
939
+ this.lastApiRebindAt = state.lastApiRebindAt;
940
+ this.pluginSource = state.pluginSource;
941
+ this.pluginVersion = state.pluginVersion;
942
+ this.lastApiInstanceId = state.lastApiInstanceId;
943
+ this.lastRegistryFingerprint = state.lastRegistryFingerprint;
944
+ this.lastDriftSnapshot = state.lastDriftSnapshot;
945
+ this.registerTraceRecent = state.registerTraceRecent;
1050
946
  }
1051
947
 
1052
948
  private buildRegisterTraceSummary() {
1053
- return buildRegisterTraceSummaryFromEntries({
1054
- traceRecent: this.registerTraceRecent,
1055
- firstRegisterAt: this.firstRegisterAt,
949
+ return buildRegisterTraceRuntimeSummary({
950
+ state: this.getRegisterTraceRuntimeState(),
1056
951
  warmupWindowMs: REGISTER_WARMUP_WINDOW_MS,
1057
952
  });
1058
953
  }
@@ -1065,43 +960,25 @@ class BncrBridgeRuntime {
1065
960
  registryFingerprint?: string;
1066
961
  }) {
1067
962
  const ts = now();
1068
- this.registerCount += 1;
1069
- if (this.firstRegisterAt == null) this.firstRegisterAt = ts;
1070
- this.lastRegisterAt = ts;
1071
- if (meta.apiRebound) {
1072
- this.apiGeneration += 1;
1073
- this.lastApiRebindAt = ts;
1074
- } else if (this.registerCount === 1 && this.apiGeneration === 0) {
1075
- this.apiGeneration = 1;
1076
- }
1077
- if (meta.source) this.pluginSource = meta.source;
1078
- if (meta.pluginVersion) this.pluginVersion = meta.pluginVersion;
1079
- if (meta.apiInstanceId) this.lastApiInstanceId = meta.apiInstanceId;
1080
- if (meta.registryFingerprint) this.lastRegistryFingerprint = meta.registryFingerprint;
1081
-
1082
963
  const stack = String(new Error().stack || '')
1083
964
  .split('\n')
1084
965
  .slice(2, 7)
1085
966
  .map((line) => line.trim())
1086
967
  .filter(Boolean)
1087
968
  .join(' <- ');
1088
- const trace = buildRegisterTraceEntry({
969
+ const state = this.getRegisterTraceRuntimeState();
970
+ const { trace, capturedDriftSnapshot } = noteRegisterTraceRuntime({
971
+ state,
972
+ meta,
1089
973
  ts,
974
+ stack,
1090
975
  bridgeId: this.bridgeId,
1091
976
  gatewayPid: this.gatewayPid,
1092
- registerCount: this.registerCount,
1093
- apiGeneration: this.apiGeneration,
1094
- apiRebound: meta.apiRebound === true,
1095
- apiInstanceId: this.lastApiInstanceId,
1096
- registryFingerprint: this.lastRegistryFingerprint,
1097
- source: this.pluginSource,
1098
- pluginVersion: this.pluginVersion,
1099
- stack,
977
+ warmupWindowMs: REGISTER_WARMUP_WINDOW_MS,
978
+ maxTraceEntries: 12,
1100
979
  });
1101
- appendBoundedRegisterTrace(this.registerTraceRecent, trace, 12);
1102
-
1103
- const summary = this.buildRegisterTraceSummary();
1104
- if (summary.postWarmupRegisterCount > 0) this.captureDriftSnapshot(summary);
980
+ this.applyRegisterTraceRuntimeState(state);
981
+ if (capturedDriftSnapshot) this.scheduleSave();
1105
982
 
1106
983
  this.logInfo('debug', `register-trace ${JSON.stringify(trace)}`, { debugOnly: true });
1107
984
  }
@@ -1202,25 +1079,18 @@ class BncrBridgeRuntime {
1202
1079
  }
1203
1080
 
1204
1081
  private buildRuntimeSurfaceDiagnostics() {
1205
- const channelRuntime = (this.api as any)?.runtime?.channel;
1206
- const surfaces = {
1207
- inbound: Boolean(channelRuntime?.inbound),
1208
- media: Boolean(channelRuntime?.media),
1209
- reply: Boolean(channelRuntime?.reply),
1210
- routing: Boolean(channelRuntime?.routing),
1211
- session: Boolean(channelRuntime?.session),
1212
- };
1213
- return {
1214
- channel: surfaces,
1215
- missing: Object.entries(surfaces)
1216
- .filter(([, present]) => !present)
1217
- .map(([name]) => name),
1218
- };
1082
+ return buildOpenClawChannelRuntimeSurfaceDiagnostics(this.api);
1219
1083
  }
1220
1084
 
1221
1085
  private buildExtendedDiagnostics(accountId: string) {
1222
1086
  const acc = normalizeAccountId(accountId);
1223
1087
  const diagnostics = this.buildIntegratedDiagnostics(acc) as Record<string, any>;
1088
+ const outboxDiagnostics = this.buildOutboxDiagnostics(acc);
1089
+ const ackObservability = this.buildRuntimeAckObservability(acc);
1090
+ const prePushGuardSkipCount = this.getCounter(this.prePushGuardSkipCountByAccount, acc);
1091
+ const lastPrePushGuardSkipAt = this.lastPrePushGuardSkipAtByAccount.get(acc) || null;
1092
+ const lastPrePushGuardSkipReason = this.lastPrePushGuardSkipReasonByAccount.get(acc) || null;
1093
+ const hasGatewayContext = Boolean(this.gatewayContext);
1224
1094
  return buildExtendedDiagnosticsFromRuntime({
1225
1095
  diagnostics,
1226
1096
  runtimeSurface: this.buildRuntimeSurfaceDiagnostics(),
@@ -1250,6 +1120,8 @@ class BncrBridgeRuntime {
1250
1120
  lastActivityAt: this.lastActivityAtGlobal,
1251
1121
  lastInboundAt: this.lastInboundAtGlobal,
1252
1122
  lastAckAt: this.lastAckAtGlobal,
1123
+ hasGatewayContext: Boolean(this.gatewayContext),
1124
+ lastGatewayContextAt: this.lastGatewayContextAt,
1253
1125
  recent: Array.from(this.recentConnections.entries()).map(([leaseId, entry]) => ({
1254
1126
  leaseId,
1255
1127
  epoch: entry.epoch,
@@ -1258,6 +1130,19 @@ class BncrBridgeRuntime {
1258
1130
  isPrimary: entry.isPrimary,
1259
1131
  })),
1260
1132
  },
1133
+ outbound: buildExtendedOutboundDiagnostics({
1134
+ outbox: outboxDiagnostics,
1135
+ enqueueCount: this.getCounter(this.outboundEnqueueCountByAccount, acc),
1136
+ lastEnqueueAt: this.lastOutboundEnqueueAtByAccount.get(acc) || null,
1137
+ prePushGuardSkipCount,
1138
+ lastPrePushGuardSkipAt,
1139
+ lastPrePushGuardSkipReason,
1140
+ hasGatewayContext,
1141
+ lastGatewayContextAt: this.lastGatewayContextAt,
1142
+ ackObservability,
1143
+ nowMs: now(),
1144
+ }),
1145
+ deadLetterSummary: this.buildDeadLetterDiagnostics(acc),
1261
1146
  protocol: {
1262
1147
  bridgeVersion: BRIDGE_VERSION,
1263
1148
  protocolVersion: 2,
@@ -1374,6 +1259,64 @@ class BncrBridgeRuntime {
1374
1259
  return map.get(normalizeAccountId(accountId)) || 0;
1375
1260
  }
1376
1261
 
1262
+ private buildDeadLetterDiagnostics(accountId: string) {
1263
+ const acc = normalizeAccountId(accountId);
1264
+ return buildDeadLetterDiagnosticsFromRuntime({
1265
+ entries: this.getAccountDeadLetterEntries(acc),
1266
+ allAccountsTotal: this.deadLetter.length,
1267
+ sinceStart: this.getCounter(this.deadLetterSinceStartByAccount, acc),
1268
+ cappedAt: MAX_DEAD_LETTER_ENTRIES,
1269
+ });
1270
+ }
1271
+
1272
+ private logDeadLetterSummary(accountId: string, options?: { force?: boolean; source?: string }) {
1273
+ const acc = normalizeAccountId(accountId);
1274
+ const summary = this.buildDeadLetterDiagnostics(acc);
1275
+ const message = [
1276
+ `${acc}|total=${summary.total}`,
1277
+ `all=${summary.allAccountsTotal}`,
1278
+ `sinceStart=${summary.sinceStart}`,
1279
+ `top=${formatDeadLetterTopReasons(summary.topReasons)}`,
1280
+ `source=${options?.source || 'update'}`,
1281
+ ].join('|');
1282
+ if (options?.force) {
1283
+ this.logInfo('deadLetter summary', message);
1284
+ return;
1285
+ }
1286
+ this.logInfoDedup('deadLetter summary', message, {
1287
+ key: `dead-letter-summary:${acc}:update`,
1288
+ sig: 'dead-letter-summary',
1289
+ windowMs: 5 * 60 * 1000,
1290
+ });
1291
+ }
1292
+
1293
+ private buildOutboxDiagnostics(accountId: string) {
1294
+ const acc = normalizeAccountId(accountId);
1295
+ return buildOutboxQueueDiagnostics({
1296
+ accountId: acc,
1297
+ outboxEntries: this.outbox.values(),
1298
+ pendingAllAccounts: this.outbox.size,
1299
+ pushConnIds: this.resolvePushConnIds(acc),
1300
+ });
1301
+ }
1302
+
1303
+ private filterDeadLetterEntries(params: {
1304
+ accountId: string;
1305
+ reason?: string | null;
1306
+ olderThan?: number | null;
1307
+ }) {
1308
+ const acc = normalizeAccountId(params.accountId);
1309
+ const reason = asString(params.reason || '').trim();
1310
+ return this.getAccountDeadLetterEntries(acc).filter((entry) => {
1311
+ if (reason && entry.lastError !== reason) return false;
1312
+ if (typeof params.olderThan === 'number') {
1313
+ const createdAt = Number(entry.createdAt);
1314
+ if (!Number.isFinite(createdAt) || createdAt >= params.olderThan) return false;
1315
+ }
1316
+ return true;
1317
+ });
1318
+ }
1319
+
1377
1320
  private async refreshDebugFlagFromConfig(options?: { forceLog?: boolean }) {
1378
1321
  try {
1379
1322
  const cfg = getOpenClawRuntimeConfig(this.api);
@@ -1423,8 +1366,6 @@ class BncrBridgeRuntime {
1423
1366
  });
1424
1367
  if (!agentId) return;
1425
1368
  this.canonicalAgentId = agentId;
1426
- this.canonicalAgentSource = 'startup';
1427
- this.canonicalAgentResolvedAt = now();
1428
1369
  }
1429
1370
 
1430
1371
  private ensureCanonicalAgentId(args: {
@@ -1438,14 +1379,10 @@ class BncrBridgeRuntime {
1438
1379
  const agentId = this.tryResolveBindingAgentId(args);
1439
1380
  if (agentId) {
1440
1381
  this.canonicalAgentId = agentId;
1441
- this.canonicalAgentSource = 'runtime';
1442
- this.canonicalAgentResolvedAt = now();
1443
1382
  return agentId;
1444
1383
  }
1445
1384
 
1446
1385
  this.canonicalAgentId = 'main';
1447
- this.canonicalAgentSource = 'fallback-main';
1448
- this.canonicalAgentResolvedAt = now();
1449
1386
  this.logWarn(
1450
1387
  'target',
1451
1388
  'binding agent unresolved; fallback to main for current process lifetime',
@@ -1455,45 +1392,23 @@ class BncrBridgeRuntime {
1455
1392
  }
1456
1393
 
1457
1394
  private countInvalidOutboxSessionKeys(accountId: string): number {
1458
- const acc = normalizeAccountId(accountId);
1459
- let count = 0;
1460
- for (const entry of this.outbox.values()) {
1461
- if (entry.accountId !== acc) continue;
1462
- if (!parseStrictBncrSessionKey(entry.sessionKey)) count += 1;
1463
- }
1464
- return count;
1395
+ return countInvalidOutboxSessionKeysFromRuntime({
1396
+ accountId,
1397
+ outboxEntries: this.outbox.values(),
1398
+ });
1465
1399
  }
1466
1400
 
1467
1401
  private countLegacyAccountResidue(accountId: string): number {
1468
- const acc = normalizeAccountId(accountId);
1469
- const mismatched = (raw?: string | null) =>
1470
- asString(raw || '').trim() && normalizeAccountId(raw) !== acc;
1471
-
1472
- let count = 0;
1473
-
1474
- for (const entry of this.outbox.values()) {
1475
- if (mismatched(entry.accountId)) count += 1;
1476
- }
1477
- for (const entry of this.deadLetter) {
1478
- if (mismatched(entry.accountId)) count += 1;
1479
- }
1480
- for (const info of this.sessionRoutes.values()) {
1481
- if (mismatched(info.accountId)) count += 1;
1482
- }
1483
- for (const key of this.lastSessionByAccount.keys()) {
1484
- if (mismatched(key)) count += 1;
1485
- }
1486
- for (const key of this.lastActivityByAccount.keys()) {
1487
- if (mismatched(key)) count += 1;
1488
- }
1489
- for (const key of this.lastInboundByAccount.keys()) {
1490
- if (mismatched(key)) count += 1;
1491
- }
1492
- for (const key of this.lastOutboundByAccount.keys()) {
1493
- if (mismatched(key)) count += 1;
1494
- }
1495
-
1496
- return count;
1402
+ return countLegacyAccountResidueFromRuntime({
1403
+ accountId,
1404
+ outboxEntries: this.outbox.values(),
1405
+ deadLetterEntries: this.deadLetter,
1406
+ sessionRoutes: this.sessionRoutes.values(),
1407
+ lastSessionAccountIds: this.lastSessionByAccount.keys(),
1408
+ lastActivityAccountIds: this.lastActivityByAccount.keys(),
1409
+ lastInboundAccountIds: this.lastInboundByAccount.keys(),
1410
+ lastOutboundAccountIds: this.lastOutboundByAccount.keys(),
1411
+ });
1497
1412
  }
1498
1413
 
1499
1414
  private buildIntegratedDiagnostics(accountId: string) {
@@ -1522,86 +1437,71 @@ class BncrBridgeRuntime {
1522
1437
  });
1523
1438
  }
1524
1439
 
1525
- private async loadState() {
1526
- if (!this.statePath) return;
1527
- const loaded = await readOpenClawJsonFileWithFallback(this.statePath, {
1528
- outbox: [],
1529
- deadLetter: [],
1530
- sessionRoutes: [],
1440
+ private normalizePersistedOutboxEntry(entry: any): OutboxEntry | null {
1441
+ return normalizePersistedOutboxEntryFromRuntime({
1442
+ entry,
1443
+ canonicalAgentId: this.canonicalAgentId,
1444
+ now,
1531
1445
  });
1532
- const data = loaded.value as PersistedState;
1533
-
1534
- this.outbox.clear();
1535
- for (const entry of data.outbox || []) {
1536
- if (!entry?.messageId) continue;
1537
- const accountId = normalizeAccountId(entry.accountId);
1538
- const sessionKey = asString(entry.sessionKey || '').trim();
1539
- const normalized = normalizeStoredSessionKey(sessionKey, this.canonicalAgentId);
1540
- if (!normalized) continue;
1541
-
1542
- const route = parseRouteLike(entry.route) || normalized.route;
1543
- const payload =
1544
- entry.payload && typeof entry.payload === 'object' ? { ...entry.payload } : {};
1545
- (payload as any).sessionKey = normalized.sessionKey;
1546
- (payload as any).platform = route.platform;
1547
- (payload as any).groupId = route.groupId;
1548
- (payload as any).userId = route.userId;
1549
-
1550
- const migratedEntry: OutboxEntry = {
1551
- ...entry,
1552
- accountId,
1553
- sessionKey: normalized.sessionKey,
1554
- route,
1555
- payload,
1556
- createdAt: finiteNumberOr(entry.createdAt, now()),
1557
- retryCount: finiteNumberOr(entry.retryCount, 0),
1558
- nextAttemptAt: finiteNumberOr(entry.nextAttemptAt, now()),
1559
- lastAttemptAt: optionalFiniteNumber(entry.lastAttemptAt),
1560
- lastError: entry.lastError ? asString(entry.lastError) : undefined,
1561
- };
1446
+ }
1562
1447
 
1563
- this.outbox.set(migratedEntry.messageId, migratedEntry);
1448
+ private loadPersistedAccountTimestampMap(target: Map<string, number>, persisted: unknown): void {
1449
+ target.clear();
1450
+ const items = Array.isArray(persisted) ? persisted.slice(-MAX_ACCOUNT_ACTIVITY_ENTRIES) : [];
1451
+ for (const item of items) {
1452
+ const accountId = normalizeAccountId(item?.accountId);
1453
+ const updatedAt = finiteNumberOr(item?.updatedAt, 0);
1454
+ if (updatedAt <= 0) continue;
1455
+ target.set(accountId, updatedAt);
1564
1456
  }
1457
+ }
1565
1458
 
1566
- this.deadLetter = [];
1567
- const persistedDeadLetter = Array.isArray(data.deadLetter)
1568
- ? data.deadLetter.slice(-MAX_DEAD_LETTER_ENTRIES)
1569
- : [];
1570
- for (const entry of persistedDeadLetter) {
1571
- if (!entry?.messageId) continue;
1572
- const accountId = normalizeAccountId(entry.accountId);
1573
- const sessionKey = asString(entry.sessionKey || '').trim();
1574
- const normalized = normalizeStoredSessionKey(sessionKey, this.canonicalAgentId);
1575
- if (!normalized) continue;
1459
+ private dumpPersistedAccountTimestampMap(source: Map<string, number>) {
1460
+ return Array.from(source.entries())
1461
+ .map(([accountId, updatedAt]) => ({
1462
+ accountId,
1463
+ updatedAt,
1464
+ }))
1465
+ .slice(-MAX_ACCOUNT_ACTIVITY_ENTRIES);
1466
+ }
1576
1467
 
1577
- const route = parseRouteLike(entry.route) || normalized.route;
1578
- const payload =
1579
- entry.payload && typeof entry.payload === 'object' ? { ...entry.payload } : {};
1580
- (payload as any).sessionKey = normalized.sessionKey;
1581
- (payload as any).platform = route.platform;
1582
- (payload as any).groupId = route.groupId;
1583
- (payload as any).userId = route.userId;
1468
+ private loadPersistedLastSessionMap(persisted: unknown): void {
1469
+ this.lastSessionByAccount.clear();
1470
+ const items = Array.isArray(persisted) ? persisted.slice(-MAX_ACCOUNT_ACTIVITY_ENTRIES) : [];
1471
+ for (const item of items) {
1472
+ const accountId = normalizeAccountId(item?.accountId);
1473
+ const normalized = normalizeStoredSessionKey(
1474
+ asString(item?.sessionKey || ''),
1475
+ this.canonicalAgentId,
1476
+ );
1477
+ const updatedAt = finiteNumberOr(item?.updatedAt, 0);
1478
+ if (!normalized || updatedAt <= 0) continue;
1584
1479
 
1585
- this.deadLetter.push({
1586
- ...entry,
1587
- accountId,
1480
+ this.lastSessionByAccount.set(accountId, {
1588
1481
  sessionKey: normalized.sessionKey,
1589
- route,
1590
- payload,
1591
- createdAt: finiteNumberOr(entry.createdAt, now()),
1592
- retryCount: finiteNumberOr(entry.retryCount, 0),
1593
- nextAttemptAt: finiteNumberOr(entry.nextAttemptAt, now()),
1594
- lastAttemptAt: optionalFiniteNumber(entry.lastAttemptAt),
1595
- lastError: entry.lastError ? asString(entry.lastError) : undefined,
1482
+ // 展示统一为 Bncr-platform:group:user
1483
+ scope: formatDisplayScope(normalized.route),
1484
+ updatedAt,
1596
1485
  });
1597
1486
  }
1487
+ }
1598
1488
 
1489
+ private dumpPersistedLastSessionMap() {
1490
+ return Array.from(this.lastSessionByAccount.entries())
1491
+ .map(([accountId, v]) => ({
1492
+ accountId,
1493
+ sessionKey: v.sessionKey,
1494
+ scope: v.scope,
1495
+ updatedAt: v.updatedAt,
1496
+ }))
1497
+ .slice(-MAX_ACCOUNT_ACTIVITY_ENTRIES);
1498
+ }
1499
+
1500
+ private loadPersistedSessionRoutes(persisted: unknown): void {
1599
1501
  this.sessionRoutes.clear();
1600
1502
  this.routeAliases.clear();
1601
- const persistedSessionRoutes = Array.isArray(data.sessionRoutes)
1602
- ? data.sessionRoutes.slice(-MAX_SESSION_ROUTE_ENTRIES)
1603
- : [];
1604
- for (const item of persistedSessionRoutes) {
1503
+ const items = Array.isArray(persisted) ? persisted.slice(-MAX_SESSION_ROUTE_ENTRIES) : [];
1504
+ for (const item of items) {
1605
1505
  const normalized = normalizeStoredSessionKey(
1606
1506
  asString(item?.sessionKey || ''),
1607
1507
  this.canonicalAgentId,
@@ -1611,186 +1511,101 @@ class BncrBridgeRuntime {
1611
1511
  const route = parseRouteLike(item?.route) || normalized.route;
1612
1512
  const accountId = normalizeAccountId(item?.accountId);
1613
1513
  const updatedAt = finiteNumberOr(item?.updatedAt, now());
1614
-
1615
- const info = {
1616
- accountId,
1617
- route,
1618
- updatedAt,
1619
- };
1514
+ const info = { accountId, route, updatedAt };
1620
1515
 
1621
1516
  this.sessionRoutes.set(normalized.sessionKey, info);
1622
1517
  this.routeAliases.set(routeKey(accountId, route), info);
1623
1518
  }
1519
+ }
1624
1520
 
1625
- this.lastSessionByAccount.clear();
1626
- const persistedLastSessionByAccount = Array.isArray(data.lastSessionByAccount)
1627
- ? data.lastSessionByAccount.slice(-MAX_ACCOUNT_ACTIVITY_ENTRIES)
1628
- : [];
1629
- for (const item of persistedLastSessionByAccount) {
1630
- const accountId = normalizeAccountId(item?.accountId);
1631
- const normalized = normalizeStoredSessionKey(
1632
- asString(item?.sessionKey || ''),
1633
- this.canonicalAgentId,
1634
- );
1635
- const updatedAt = finiteNumberOr(item?.updatedAt, 0);
1636
- if (!normalized || updatedAt <= 0) continue;
1521
+ private dumpPersistedSessionRoutes() {
1522
+ return Array.from(this.sessionRoutes.entries())
1523
+ .map(([sessionKey, v]) => ({
1524
+ sessionKey,
1525
+ accountId: v.accountId,
1526
+ route: v.route,
1527
+ updatedAt: v.updatedAt,
1528
+ }))
1529
+ .slice(-MAX_SESSION_ROUTE_ENTRIES);
1530
+ }
1637
1531
 
1638
- this.lastSessionByAccount.set(accountId, {
1639
- sessionKey: normalized.sessionKey,
1640
- // 展示统一为 Bncr-platform:group:user
1641
- scope: formatDisplayScope(normalized.route),
1642
- updatedAt,
1643
- });
1644
- }
1532
+ private backfillAccountActivityFromSessionRoutes(): void {
1533
+ if (this.lastSessionByAccount.size > 0 || this.sessionRoutes.size === 0) return;
1645
1534
 
1646
- this.lastActivityByAccount.clear();
1647
- const persistedLastActivityByAccount = Array.isArray(data.lastActivityByAccount)
1648
- ? data.lastActivityByAccount.slice(-MAX_ACCOUNT_ACTIVITY_ENTRIES)
1649
- : [];
1650
- for (const item of persistedLastActivityByAccount) {
1651
- const accountId = normalizeAccountId(item?.accountId);
1652
- const updatedAt = finiteNumberOr(item?.updatedAt, 0);
1535
+ for (const [sessionKey, info] of this.sessionRoutes.entries()) {
1536
+ const acc = normalizeAccountId(info.accountId);
1537
+ const updatedAt = finiteNumberOr(info.updatedAt, 0);
1653
1538
  if (updatedAt <= 0) continue;
1654
- this.lastActivityByAccount.set(accountId, updatedAt);
1539
+
1540
+ const current = this.lastSessionByAccount.get(acc);
1541
+ if (!current || updatedAt >= current.updatedAt) {
1542
+ this.lastSessionByAccount.set(acc, {
1543
+ sessionKey,
1544
+ // 回填时统一展示为 Bncr-platform:group:user
1545
+ scope: formatDisplayScope(info.route),
1546
+ updatedAt,
1547
+ });
1548
+ }
1549
+
1550
+ const lastAct = this.lastActivityByAccount.get(acc) || 0;
1551
+ if (updatedAt > lastAct) this.lastActivityByAccount.set(acc, updatedAt);
1552
+
1553
+ const lastIn = this.lastInboundByAccount.get(acc) || 0;
1554
+ if (updatedAt > lastIn) this.lastInboundByAccount.set(acc, updatedAt);
1655
1555
  }
1556
+ }
1656
1557
 
1657
- this.lastInboundByAccount.clear();
1658
- const persistedLastInboundByAccount = Array.isArray(data.lastInboundByAccount)
1659
- ? data.lastInboundByAccount.slice(-MAX_ACCOUNT_ACTIVITY_ENTRIES)
1660
- : [];
1661
- for (const item of persistedLastInboundByAccount) {
1662
- const accountId = normalizeAccountId(item?.accountId);
1663
- const updatedAt = finiteNumberOr(item?.updatedAt, 0);
1664
- if (updatedAt <= 0) continue;
1665
- this.lastInboundByAccount.set(accountId, updatedAt);
1558
+ private async loadState() {
1559
+ if (!this.statePath) return;
1560
+ const loaded = await readOpenClawJsonFileWithFallback(this.statePath, {
1561
+ outbox: [],
1562
+ deadLetter: [],
1563
+ sessionRoutes: [],
1564
+ });
1565
+ const data = loaded.value as PersistedState;
1566
+
1567
+ this.outbox.clear();
1568
+ for (const entry of data.outbox || []) {
1569
+ const migratedEntry = this.normalizePersistedOutboxEntry(entry);
1570
+ if (!migratedEntry) continue;
1571
+ this.outbox.set(migratedEntry.messageId, migratedEntry);
1666
1572
  }
1667
1573
 
1668
- this.lastOutboundByAccount.clear();
1669
- const persistedLastOutboundByAccount = Array.isArray(data.lastOutboundByAccount)
1670
- ? data.lastOutboundByAccount.slice(-MAX_ACCOUNT_ACTIVITY_ENTRIES)
1574
+ this.deadLetter = [];
1575
+ const persistedDeadLetter = Array.isArray(data.deadLetter)
1576
+ ? data.deadLetter.slice(-MAX_DEAD_LETTER_ENTRIES)
1671
1577
  : [];
1672
- for (const item of persistedLastOutboundByAccount) {
1673
- const accountId = normalizeAccountId(item?.accountId);
1674
- const updatedAt = finiteNumberOr(item?.updatedAt, 0);
1675
- if (updatedAt <= 0) continue;
1676
- this.lastOutboundByAccount.set(accountId, updatedAt);
1578
+ for (const entry of persistedDeadLetter) {
1579
+ const migratedEntry = this.normalizePersistedOutboxEntry(entry);
1580
+ if (!migratedEntry) continue;
1581
+ this.deadLetter.push(migratedEntry);
1677
1582
  }
1678
1583
 
1679
- this.lastDriftSnapshot =
1680
- data.lastDriftSnapshot && typeof data.lastDriftSnapshot === 'object'
1681
- ? {
1682
- capturedAt: finiteNumberOr((data.lastDriftSnapshot as any).capturedAt, 0),
1683
- registerCount: Number.isFinite(Number((data.lastDriftSnapshot as any).registerCount))
1684
- ? Number((data.lastDriftSnapshot as any).registerCount)
1685
- : null,
1686
- apiGeneration: Number.isFinite(Number((data.lastDriftSnapshot as any).apiGeneration))
1687
- ? Number((data.lastDriftSnapshot as any).apiGeneration)
1688
- : null,
1689
- postWarmupRegisterCount: Number.isFinite(
1690
- Number((data.lastDriftSnapshot as any).postWarmupRegisterCount),
1691
- )
1692
- ? Number((data.lastDriftSnapshot as any).postWarmupRegisterCount)
1693
- : null,
1694
- apiInstanceId:
1695
- asString((data.lastDriftSnapshot as any).apiInstanceId || '').trim() || null,
1696
- registryFingerprint:
1697
- asString((data.lastDriftSnapshot as any).registryFingerprint || '').trim() || null,
1698
- dominantBucket:
1699
- asString((data.lastDriftSnapshot as any).dominantBucket || '').trim() || null,
1700
- sourceBuckets:
1701
- (data.lastDriftSnapshot as any).sourceBuckets &&
1702
- typeof (data.lastDriftSnapshot as any).sourceBuckets === 'object'
1703
- ? { ...((data.lastDriftSnapshot as any).sourceBuckets as Record<string, number>) }
1704
- : {},
1705
- traceWindowSize: finiteNumberOr((data.lastDriftSnapshot as any).traceWindowSize, 0),
1706
- traceRecent: Array.isArray((data.lastDriftSnapshot as any).traceRecent)
1707
- ? [...((data.lastDriftSnapshot as any).traceRecent as Array<Record<string, unknown>>)]
1708
- : [],
1709
- }
1710
- : null;
1584
+ this.loadPersistedSessionRoutes(data.sessionRoutes);
1711
1585
 
1712
- // 兼容旧状态文件:若尚未持久化 lastSession*/lastActivity*,从 sessionRoutes 回填。
1713
- if (this.lastSessionByAccount.size === 0 && this.sessionRoutes.size > 0) {
1714
- for (const [sessionKey, info] of this.sessionRoutes.entries()) {
1715
- const acc = normalizeAccountId(info.accountId);
1716
- const updatedAt = finiteNumberOr(info.updatedAt, 0);
1717
- if (updatedAt <= 0) continue;
1718
-
1719
- const current = this.lastSessionByAccount.get(acc);
1720
- if (!current || updatedAt >= current.updatedAt) {
1721
- this.lastSessionByAccount.set(acc, {
1722
- sessionKey,
1723
- // 回填时统一展示为 Bncr-platform:group:user
1724
- scope: formatDisplayScope(info.route),
1725
- updatedAt,
1726
- });
1727
- }
1586
+ this.loadPersistedLastSessionMap(data.lastSessionByAccount);
1587
+ this.loadPersistedAccountTimestampMap(this.lastActivityByAccount, data.lastActivityByAccount);
1588
+ this.loadPersistedAccountTimestampMap(this.lastInboundByAccount, data.lastInboundByAccount);
1589
+ this.loadPersistedAccountTimestampMap(this.lastOutboundByAccount, data.lastOutboundByAccount);
1728
1590
 
1729
- const lastAct = this.lastActivityByAccount.get(acc) || 0;
1730
- if (updatedAt > lastAct) this.lastActivityByAccount.set(acc, updatedAt);
1591
+ this.lastDriftSnapshot = normalizeRegisterDriftSnapshot(data.lastDriftSnapshot);
1731
1592
 
1732
- const lastIn = this.lastInboundByAccount.get(acc) || 0;
1733
- if (updatedAt > lastIn) this.lastInboundByAccount.set(acc, updatedAt);
1734
- }
1735
- }
1593
+ // 兼容旧状态文件:若尚未持久化 lastSession*/lastActivity*,从 sessionRoutes 回填。
1594
+ this.backfillAccountActivityFromSessionRoutes();
1736
1595
  }
1737
1596
 
1738
1597
  private async flushState() {
1739
1598
  if (!this.statePath) return;
1740
1599
 
1741
- const sessionRoutes = Array.from(this.sessionRoutes.entries())
1742
- .map(([sessionKey, v]) => ({
1743
- sessionKey,
1744
- accountId: v.accountId,
1745
- route: v.route,
1746
- updatedAt: v.updatedAt,
1747
- }))
1748
- .slice(-MAX_SESSION_ROUTE_ENTRIES);
1749
-
1750
1600
  const data: PersistedState = {
1751
1601
  outbox: Array.from(this.outbox.values()),
1752
1602
  deadLetter: this.deadLetter.slice(-MAX_DEAD_LETTER_ENTRIES),
1753
- sessionRoutes,
1754
- lastSessionByAccount: Array.from(this.lastSessionByAccount.entries())
1755
- .map(([accountId, v]) => ({
1756
- accountId,
1757
- sessionKey: v.sessionKey,
1758
- scope: v.scope,
1759
- updatedAt: v.updatedAt,
1760
- }))
1761
- .slice(-MAX_ACCOUNT_ACTIVITY_ENTRIES),
1762
- lastActivityByAccount: Array.from(this.lastActivityByAccount.entries())
1763
- .map(([accountId, updatedAt]) => ({
1764
- accountId,
1765
- updatedAt,
1766
- }))
1767
- .slice(-MAX_ACCOUNT_ACTIVITY_ENTRIES),
1768
- lastInboundByAccount: Array.from(this.lastInboundByAccount.entries())
1769
- .map(([accountId, updatedAt]) => ({
1770
- accountId,
1771
- updatedAt,
1772
- }))
1773
- .slice(-MAX_ACCOUNT_ACTIVITY_ENTRIES),
1774
- lastOutboundByAccount: Array.from(this.lastOutboundByAccount.entries())
1775
- .map(([accountId, updatedAt]) => ({
1776
- accountId,
1777
- updatedAt,
1778
- }))
1779
- .slice(-MAX_ACCOUNT_ACTIVITY_ENTRIES),
1780
- lastDriftSnapshot: this.lastDriftSnapshot
1781
- ? {
1782
- capturedAt: this.lastDriftSnapshot.capturedAt,
1783
- registerCount: this.lastDriftSnapshot.registerCount,
1784
- apiGeneration: this.lastDriftSnapshot.apiGeneration,
1785
- postWarmupRegisterCount: this.lastDriftSnapshot.postWarmupRegisterCount,
1786
- apiInstanceId: this.lastDriftSnapshot.apiInstanceId,
1787
- registryFingerprint: this.lastDriftSnapshot.registryFingerprint,
1788
- dominantBucket: this.lastDriftSnapshot.dominantBucket,
1789
- sourceBuckets: { ...this.lastDriftSnapshot.sourceBuckets },
1790
- traceWindowSize: this.lastDriftSnapshot.traceWindowSize,
1791
- traceRecent: this.lastDriftSnapshot.traceRecent.map((trace) => ({ ...trace })),
1792
- }
1793
- : null,
1603
+ sessionRoutes: this.dumpPersistedSessionRoutes(),
1604
+ lastSessionByAccount: this.dumpPersistedLastSessionMap(),
1605
+ lastActivityByAccount: this.dumpPersistedAccountTimestampMap(this.lastActivityByAccount),
1606
+ lastInboundByAccount: this.dumpPersistedAccountTimestampMap(this.lastInboundByAccount),
1607
+ lastOutboundByAccount: this.dumpPersistedAccountTimestampMap(this.lastOutboundByAccount),
1608
+ lastDriftSnapshot: dumpRegisterDriftSnapshot(this.lastDriftSnapshot),
1794
1609
  };
1795
1610
 
1796
1611
  await writeOpenClawJsonFileAtomically(this.statePath, data);
@@ -1808,7 +1623,9 @@ class BncrBridgeRuntime {
1808
1623
  }
1809
1624
 
1810
1625
  private rememberGatewayContext(context: GatewayRequestHandlerOptions['context']) {
1811
- if (context) this.gatewayContext = context;
1626
+ if (!context) return;
1627
+ this.gatewayContext = context;
1628
+ this.lastGatewayContextAt = now();
1812
1629
  }
1813
1630
 
1814
1631
  private resolveOutboxPushOwner(accountId: string): BncrConnection | null {
@@ -1817,64 +1634,30 @@ class BncrBridgeRuntime {
1817
1634
  const primaryKey = this.activeConnectionByAccount.get(acc);
1818
1635
  const primary = primaryKey ? this.connections.get(primaryKey) : null;
1819
1636
 
1820
- const isEligible = (
1821
- conn: BncrConnection | null | undefined,
1822
- ): conn is BncrConnection & {
1823
- outboundReadyUntil?: number;
1824
- preferredForOutboundUntil?: number;
1825
- inboundOnly?: boolean;
1826
- } => {
1827
- if (!conn?.connId) return false;
1828
- if (t - conn.lastSeenAt > CONNECT_TTL_MS) return false;
1829
- if ((conn as any).inboundOnly === true) return false;
1830
- return true;
1831
- };
1832
-
1833
1637
  const recentInboundConnIds = this.resolveRecentInboundConnIds(acc);
1834
- const candidateScore = (conn: BncrConnection) => {
1835
- const preferredForOutboundUntil = finiteNumberOr((conn as any).preferredForOutboundUntil, 0);
1836
- const outboundReadyUntil = finiteNumberOr((conn as any).outboundReadyUntil, 0);
1837
- const lastPushTimeoutAt = finiteNumberOr((conn as any).lastPushTimeoutAt, 0);
1838
- const lastAckOkAt = finiteNumberOr((conn as any).lastAckOkAt, 0);
1839
- const pushFailureScore = finiteNumberOr((conn as any).pushFailureScore, 0);
1840
- const recentTimeoutPenalty = lastPushTimeoutAt > 0 && t - lastPushTimeoutAt <= 30_000 ? 1 : 0;
1841
- return {
1842
- preferred: preferredForOutboundUntil > t ? 1 : 0,
1843
- ready: outboundReadyUntil > t ? 1 : 0,
1844
- recentInbound: recentInboundConnIds.has(conn.connId) ? 1 : 0,
1845
- recentTimeoutPenalty,
1846
- pushFailureScore,
1847
- lastAckOkAt,
1848
- lastPushTimeoutAt,
1849
- lastSeenAt: conn.lastSeenAt,
1850
- connectedAt: conn.connectedAt,
1851
- };
1852
- };
1853
1638
 
1854
- if (isEligible(primary)) {
1855
- const score = candidateScore(primary);
1856
- if (score.preferred || score.ready) return primary;
1639
+ if (
1640
+ isEligibleOutboundPushConnection({
1641
+ connection: primary,
1642
+ now: t,
1643
+ connectTtlMs: CONNECT_TTL_MS,
1644
+ })
1645
+ ) {
1646
+ const preferredForOutboundUntil = finiteNumberOr(
1647
+ (primary as any).preferredForOutboundUntil,
1648
+ 0,
1649
+ );
1650
+ const outboundReadyUntil = finiteNumberOr((primary as any).outboundReadyUntil, 0);
1651
+ if (preferredForOutboundUntil > t || outboundReadyUntil > t) return primary;
1857
1652
  }
1858
1653
 
1859
- const candidates = Array.from(this.connections.values())
1860
- .filter((c): c is BncrConnection => c.accountId === acc)
1861
- .filter((c) => isEligible(c))
1862
- .sort((a, b) => {
1863
- const sa = candidateScore(a);
1864
- const sb = candidateScore(b);
1865
- if (sb.preferred !== sa.preferred) return sb.preferred - sa.preferred;
1866
- if (sb.ready !== sa.ready) return sb.ready - sa.ready;
1867
- if (sa.recentTimeoutPenalty !== sb.recentTimeoutPenalty)
1868
- return sa.recentTimeoutPenalty - sb.recentTimeoutPenalty;
1869
- if (sa.pushFailureScore !== sb.pushFailureScore)
1870
- return sa.pushFailureScore - sb.pushFailureScore;
1871
- if (sb.lastAckOkAt !== sa.lastAckOkAt) return sb.lastAckOkAt - sa.lastAckOkAt;
1872
- if (sa.lastPushTimeoutAt !== sb.lastPushTimeoutAt)
1873
- return sa.lastPushTimeoutAt - sb.lastPushTimeoutAt;
1874
- if (sb.recentInbound !== sa.recentInbound) return sb.recentInbound - sa.recentInbound;
1875
- if (sb.lastSeenAt !== sa.lastSeenAt) return sb.lastSeenAt - sa.lastSeenAt;
1876
- return sb.connectedAt - sa.connectedAt;
1877
- });
1654
+ const candidates = selectOrderedOutboundPushConnections({
1655
+ accountId: acc,
1656
+ now: t,
1657
+ connectTtlMs: CONNECT_TTL_MS,
1658
+ recentInboundConnIds,
1659
+ connections: this.connections.values(),
1660
+ });
1878
1661
 
1879
1662
  const next = candidates[0] || null;
1880
1663
  if (!next) return null;
@@ -1918,67 +1701,29 @@ class BncrBridgeRuntime {
1918
1701
  const t = now();
1919
1702
  const connIds = new Set<string>();
1920
1703
 
1921
- const isEligible = (
1922
- conn: BncrConnection | null | undefined,
1923
- ): conn is BncrConnection & {
1924
- outboundReadyUntil?: number;
1925
- preferredForOutboundUntil?: number;
1926
- inboundOnly?: boolean;
1927
- } => {
1928
- if (!conn?.connId) return false;
1929
- if (t - conn.lastSeenAt > CONNECT_TTL_MS) return false;
1930
- if ((conn as any).inboundOnly === true) return false;
1931
- return true;
1932
- };
1933
-
1934
1704
  const recentInboundConnIds = this.resolveRecentInboundConnIds(acc);
1935
- const candidateScore = (conn: BncrConnection) => {
1936
- const preferredForOutboundUntil = finiteNumberOr((conn as any).preferredForOutboundUntil, 0);
1937
- const outboundReadyUntil = finiteNumberOr((conn as any).outboundReadyUntil, 0);
1938
- const lastPushTimeoutAt = finiteNumberOr((conn as any).lastPushTimeoutAt, 0);
1939
- const lastAckOkAt = finiteNumberOr((conn as any).lastAckOkAt, 0);
1940
- const pushFailureScore = finiteNumberOr((conn as any).pushFailureScore, 0);
1941
- const recentTimeoutPenalty = lastPushTimeoutAt > 0 && t - lastPushTimeoutAt <= 30_000 ? 1 : 0;
1942
- return {
1943
- preferred: preferredForOutboundUntil > t ? 1 : 0,
1944
- ready: outboundReadyUntil > t ? 1 : 0,
1945
- recentInbound: recentInboundConnIds.has(conn.connId) ? 1 : 0,
1946
- recentTimeoutPenalty,
1947
- pushFailureScore,
1948
- lastAckOkAt,
1949
- lastPushTimeoutAt,
1950
- lastSeenAt: conn.lastSeenAt,
1951
- connectedAt: conn.connectedAt,
1952
- };
1953
- };
1954
1705
 
1955
1706
  const primaryKey = this.activeConnectionByAccount.get(acc);
1956
1707
  if (primaryKey) {
1957
1708
  const primary = this.connections.get(primaryKey);
1958
- if (isEligible(primary)) {
1709
+ if (
1710
+ isEligibleOutboundPushConnection({
1711
+ connection: primary,
1712
+ now: t,
1713
+ connectTtlMs: CONNECT_TTL_MS,
1714
+ })
1715
+ ) {
1959
1716
  connIds.add(primary.connId);
1960
1717
  }
1961
1718
  }
1962
1719
 
1963
- const candidates = Array.from(this.connections.values())
1964
- .filter((c): c is BncrConnection => c.accountId === acc)
1965
- .filter((c) => isEligible(c))
1966
- .sort((a, b) => {
1967
- const sa = candidateScore(a);
1968
- const sb = candidateScore(b);
1969
- if (sb.preferred !== sa.preferred) return sb.preferred - sa.preferred;
1970
- if (sb.ready !== sa.ready) return sb.ready - sa.ready;
1971
- if (sa.recentTimeoutPenalty !== sb.recentTimeoutPenalty)
1972
- return sa.recentTimeoutPenalty - sb.recentTimeoutPenalty;
1973
- if (sa.pushFailureScore !== sb.pushFailureScore)
1974
- return sa.pushFailureScore - sb.pushFailureScore;
1975
- if (sb.lastAckOkAt !== sa.lastAckOkAt) return sb.lastAckOkAt - sa.lastAckOkAt;
1976
- if (sa.lastPushTimeoutAt !== sb.lastPushTimeoutAt)
1977
- return sa.lastPushTimeoutAt - sb.lastPushTimeoutAt;
1978
- if (sb.recentInbound !== sa.recentInbound) return sb.recentInbound - sa.recentInbound;
1979
- if (sb.lastSeenAt !== sa.lastSeenAt) return sb.lastSeenAt - sa.lastSeenAt;
1980
- return sb.connectedAt - sa.connectedAt;
1981
- });
1720
+ const candidates = selectOrderedOutboundPushConnections({
1721
+ accountId: acc,
1722
+ now: t,
1723
+ connectTtlMs: CONNECT_TTL_MS,
1724
+ recentInboundConnIds,
1725
+ connections: this.connections.values(),
1726
+ });
1982
1727
 
1983
1728
  for (const c of candidates) {
1984
1729
  connIds.add(c.connId);
@@ -2156,10 +1901,7 @@ class BncrBridgeRuntime {
2156
1901
  );
2157
1902
  }
2158
1903
 
2159
- private handleFileTransferPushFailure(args: {
2160
- entry: OutboxEntry;
2161
- error: unknown;
2162
- }) {
1904
+ private handleFileTransferPushFailure(args: { entry: OutboxEntry; error: unknown }) {
2163
1905
  this.recordOutboxPushFailure({
2164
1906
  entry: args.entry,
2165
1907
  error: args.error,
@@ -2278,6 +2020,7 @@ class BncrBridgeRuntime {
2278
2020
  audioAsVoice?: boolean;
2279
2021
  kind?: 'tool' | 'block' | 'final';
2280
2022
  replyToId?: string;
2023
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
2281
2024
  }): OutboxEntry {
2282
2025
  return buildFileTransferOutboxEntryFromRuntime({
2283
2026
  createMessageId: () => randomUUID(),
@@ -2294,6 +2037,7 @@ class BncrBridgeRuntime {
2294
2037
  audioAsVoice: params.audioAsVoice,
2295
2038
  kind: params.kind,
2296
2039
  replyToId: asString(params.replyToId || '').trim() || undefined,
2040
+ replyTargetPolicy: params.replyTargetPolicy,
2297
2041
  });
2298
2042
  }
2299
2043
 
@@ -2404,6 +2148,7 @@ class BncrBridgeRuntime {
2404
2148
  text: string;
2405
2149
  kind?: 'tool' | 'block' | 'final';
2406
2150
  replyToId?: string;
2151
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
2407
2152
  }): OutboxEntry {
2408
2153
  return buildTextOutboxEntryFromRuntime({
2409
2154
  createMessageId: () => randomUUID(),
@@ -2416,6 +2161,7 @@ class BncrBridgeRuntime {
2416
2161
  text: params.text,
2417
2162
  kind: params.kind,
2418
2163
  replyToId: params.replyToId,
2164
+ replyTargetPolicy: params.replyTargetPolicy,
2419
2165
  });
2420
2166
  }
2421
2167
 
@@ -2473,10 +2219,7 @@ class BncrBridgeRuntime {
2473
2219
  );
2474
2220
  }
2475
2221
 
2476
- private handleTextPushFailure(args: {
2477
- entry: OutboxEntry;
2478
- error: unknown;
2479
- }) {
2222
+ private handleTextPushFailure(args: { entry: OutboxEntry; error: unknown }) {
2480
2223
  this.recordOutboxPushFailure({
2481
2224
  entry: args.entry,
2482
2225
  error: args.error,
@@ -2558,6 +2301,7 @@ class BncrBridgeRuntime {
2558
2301
  ownerConnId?: string;
2559
2302
  ownerClientId?: string;
2560
2303
  }) {
2304
+ this.recordPrePushGuardSkip({ accountId: args.accountId, reason: args.reason });
2561
2305
  this.logInfo(
2562
2306
  'outbox push skip',
2563
2307
  `mid=${args.messageId}|q=${this.outbox.size}|reason=${args.reason}${args.kind ? `|kind=${args.kind}` : ''}`,
@@ -3148,6 +2892,25 @@ class BncrBridgeRuntime {
3148
2892
  if (args.persist) this.scheduleSave();
3149
2893
  }
3150
2894
 
2895
+ private isPrePushGuardReason(reason: string) {
2896
+ return reason === 'no-gateway-context' || reason === 'no-active-connection';
2897
+ }
2898
+
2899
+ private recordPrePushGuardSkip(args: { accountId: string; reason: string }) {
2900
+ if (!this.isPrePushGuardReason(args.reason)) return;
2901
+ const acc = normalizeAccountId(args.accountId);
2902
+ this.incrementCounter(this.prePushGuardSkipCountByAccount, acc);
2903
+ this.lastPrePushGuardSkipAtByAccount.set(acc, now());
2904
+ this.lastPrePushGuardSkipReasonByAccount.set(acc, args.reason);
2905
+ }
2906
+
2907
+ private isPrePushGuardDeferral(entry: OutboxEntry) {
2908
+ return (
2909
+ entry.lastError === 'gateway context unavailable' ||
2910
+ entry.lastError === 'no active bncr client'
2911
+ );
2912
+ }
2913
+
3151
2914
  private recordOutboxPushFailure(args: {
3152
2915
  entry: OutboxEntry;
3153
2916
  error: unknown;
@@ -3263,11 +3026,42 @@ class BncrBridgeRuntime {
3263
3026
  return Array.from(this.outbox.values()).filter((entry) => entry.accountId === acc);
3264
3027
  }
3265
3028
 
3266
- private maybeLogOutboxDrainStuck(args: {
3267
- accountId: string;
3268
- trigger: string;
3269
- reason: string;
3270
- }) {
3029
+ private getAccountDeadLetterEntries(accountId: string) {
3030
+ const acc = normalizeAccountId(accountId);
3031
+ return this.deadLetter.filter((entry) => entry.accountId === acc);
3032
+ }
3033
+
3034
+ private buildAccountQueueCounters(accountId: string) {
3035
+ return {
3036
+ activeConnections: this.activeConnectionCount(accountId),
3037
+ pending: this.getAccountPendingOutboxEntries(accountId).length,
3038
+ deadLetter: this.getAccountDeadLetterEntries(accountId).length,
3039
+ };
3040
+ }
3041
+
3042
+ private buildActiveConnectionDebugList(
3043
+ accountId: string,
3044
+ options?: { includeOutboundState?: boolean },
3045
+ ) {
3046
+ const acc = normalizeAccountId(accountId);
3047
+ return Array.from(this.connections.values())
3048
+ .filter((conn) => conn.accountId === acc)
3049
+ .map((conn) => ({
3050
+ connId: conn.connId,
3051
+ clientId: conn.clientId,
3052
+ connectedAt: conn.connectedAt,
3053
+ lastSeenAt: conn.lastSeenAt,
3054
+ ...(options?.includeOutboundState
3055
+ ? {
3056
+ outboundReadyUntil: (conn as any).outboundReadyUntil || null,
3057
+ preferredForOutboundUntil: (conn as any).preferredForOutboundUntil || null,
3058
+ inboundOnly: (conn as any).inboundOnly === true,
3059
+ }
3060
+ : {}),
3061
+ }));
3062
+ }
3063
+
3064
+ private maybeLogOutboxDrainStuck(args: { accountId: string; trigger: string; reason: string }) {
3271
3065
  const acc = normalizeAccountId(args.accountId);
3272
3066
  const startedAt = this.pushDrainRunningSinceByAccount.get(acc) || 0;
3273
3067
  if (!startedAt) return;
@@ -3637,6 +3431,26 @@ class BncrBridgeRuntime {
3637
3431
  continue;
3638
3432
  }
3639
3433
 
3434
+ if (this.isPrePushGuardDeferral(entry)) {
3435
+ const wait = PRE_PUSH_GUARD_RETRY_DELAY_MS;
3436
+ localNextDelay = updateMinOutboxDelay(localNextDelay, wait);
3437
+ this.logInfo(
3438
+ 'outbox',
3439
+ `schedule ${JSON.stringify(
3440
+ buildOutboxScheduleDebugInfo({
3441
+ bridgeId: this.bridgeId,
3442
+ accountId: acc,
3443
+ messageId: entry.messageId,
3444
+ source: OUTBOUND_SCHEDULE_SOURCE.PRE_PUSH_GUARD_WAIT,
3445
+ wait,
3446
+ localNextDelay,
3447
+ }),
3448
+ )}`,
3449
+ { debugOnly: true },
3450
+ );
3451
+ break;
3452
+ }
3453
+
3640
3454
  const decision = computePushFailureDecision(
3641
3455
  {
3642
3456
  nowMs: t,
@@ -3877,17 +3691,9 @@ class BncrBridgeRuntime {
3877
3691
  previousActiveConn,
3878
3692
  nextActiveKey: key,
3879
3693
  nextActiveConn: nextConn,
3880
- activeConnections: Array.from(this.connections.values())
3881
- .filter((c) => c.accountId === acc)
3882
- .map((c) => ({
3883
- connId: c.connId,
3884
- clientId: c.clientId,
3885
- connectedAt: c.connectedAt,
3886
- lastSeenAt: c.lastSeenAt,
3887
- outboundReadyUntil: (c as any).outboundReadyUntil || null,
3888
- preferredForOutboundUntil: (c as any).preferredForOutboundUntil || null,
3889
- inboundOnly: (c as any).inboundOnly === true,
3890
- })),
3694
+ activeConnections: this.buildActiveConnectionDebugList(acc, {
3695
+ includeOutboundState: true,
3696
+ }),
3891
3697
  })}`,
3892
3698
  { debugOnly: true },
3893
3699
  );
@@ -3907,17 +3713,9 @@ class BncrBridgeRuntime {
3907
3713
  previousActiveConn,
3908
3714
  nextActiveKey: key,
3909
3715
  nextActiveConn: nextConn,
3910
- activeConnections: Array.from(this.connections.values())
3911
- .filter((c) => c.accountId === acc)
3912
- .map((c) => ({
3913
- connId: c.connId,
3914
- clientId: c.clientId,
3915
- connectedAt: c.connectedAt,
3916
- lastSeenAt: c.lastSeenAt,
3917
- outboundReadyUntil: (c as any).outboundReadyUntil || null,
3918
- preferredForOutboundUntil: (c as any).preferredForOutboundUntil || null,
3919
- inboundOnly: (c as any).inboundOnly === true,
3920
- })),
3716
+ activeConnections: this.buildActiveConnectionDebugList(acc, {
3717
+ includeOutboundState: true,
3718
+ }),
3921
3719
  })}`,
3922
3720
  { debugOnly: true },
3923
3721
  );
@@ -4408,76 +4206,6 @@ class BncrBridgeRuntime {
4408
4206
  return true;
4409
4207
  }
4410
4208
 
4411
- private pushFileEventToAccount(
4412
- accountId: string,
4413
- event: string,
4414
- payload: Record<string, unknown>,
4415
- ) {
4416
- const connIds = this.resolvePushConnIds(accountId);
4417
- if (!connIds.size || !this.gatewayContext) {
4418
- throw new Error(`no active bncr connection for account=${accountId}`);
4419
- }
4420
- const normalizedEvent =
4421
- event === 'bncr.file.init'
4422
- ? BNCR_FILE_INIT_EVENT
4423
- : event === 'bncr.file.chunk'
4424
- ? BNCR_FILE_CHUNK_EVENT
4425
- : event === 'bncr.file.complete'
4426
- ? BNCR_FILE_COMPLETE_EVENT
4427
- : event === 'bncr.file.abort'
4428
- ? BNCR_FILE_ABORT_EVENT
4429
- : event;
4430
- this.gatewayContext.broadcastToConnIds(normalizedEvent, payload, connIds);
4431
- }
4432
-
4433
- private resolveInboundFileType(mimeType: string, fileName: string): string {
4434
- const mt = asString(mimeType).toLowerCase();
4435
- const fn = asString(fileName).toLowerCase();
4436
- if (mt.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(fn)) return 'image';
4437
- if (mt.startsWith('video/') || /\.(mp4|mov|mkv|avi|webm)$/.test(fn)) return 'video';
4438
- if (mt.startsWith('audio/') || /\.(mp3|wav|m4a|aac|ogg|flac)$/.test(fn)) return 'audio';
4439
- return mt || 'file';
4440
- }
4441
-
4442
- private computeRecommendedAckTimeoutReason(args: {
4443
- lateAckOkCount: number;
4444
- recentAckTimeoutCount: number;
4445
- lastLateAckPushLatencyMs: number | null;
4446
- lastLateAckOkAt?: number | null;
4447
- adaptiveAckRecoveryOkCount?: number;
4448
- recommendedAckTimeoutMs?: number;
4449
- nowMs?: number;
4450
- }) {
4451
- return computeBncrRecommendedAckTimeoutReason({
4452
- ...args,
4453
- nowMs: typeof args.nowMs === 'number' ? args.nowMs : now(),
4454
- defaultAckTimeoutMs: PUSH_ACK_TIMEOUT_MS,
4455
- minAckTimeoutMs: RECOMMENDED_ACK_TIMEOUT_MIN_MS,
4456
- maxAckTimeoutMs: RECOMMENDED_ACK_TIMEOUT_MAX_MS,
4457
- lateAckObservationTtlMs: ADAPTIVE_ACK_TIMEOUT_OBSERVATION_TTL_MS,
4458
- recoveryOkThreshold: ADAPTIVE_ACK_TIMEOUT_RECOVERY_OK_THRESHOLD,
4459
- });
4460
- }
4461
-
4462
- private computeRecommendedAckTimeoutMs(args: {
4463
- lateAckOkCount: number;
4464
- recentAckTimeoutCount: number;
4465
- lastLateAckPushLatencyMs: number | null;
4466
- lastLateAckOkAt?: number | null;
4467
- adaptiveAckRecoveryOkCount?: number;
4468
- nowMs?: number;
4469
- }) {
4470
- return computeBncrRecommendedAckTimeoutMs({
4471
- ...args,
4472
- nowMs: typeof args.nowMs === 'number' ? args.nowMs : now(),
4473
- defaultAckTimeoutMs: PUSH_ACK_TIMEOUT_MS,
4474
- minAckTimeoutMs: RECOMMENDED_ACK_TIMEOUT_MIN_MS,
4475
- maxAckTimeoutMs: RECOMMENDED_ACK_TIMEOUT_MAX_MS,
4476
- lateAckObservationTtlMs: ADAPTIVE_ACK_TIMEOUT_OBSERVATION_TTL_MS,
4477
- recoveryOkThreshold: ADAPTIVE_ACK_TIMEOUT_RECOVERY_OK_THRESHOLD,
4478
- });
4479
- }
4480
-
4481
4209
  private maybeLogAdaptiveAckTimeout(args: {
4482
4210
  accountId: string;
4483
4211
  timeoutMs: number;
@@ -4525,22 +4253,18 @@ class BncrBridgeRuntime {
4525
4253
  acc,
4526
4254
  );
4527
4255
  const nowMs = now();
4528
- const timeoutMs = this.computeRecommendedAckTimeoutMs({
4529
- lateAckOkCount,
4530
- recentAckTimeoutCount,
4531
- lastLateAckPushLatencyMs,
4532
- lastLateAckOkAt,
4533
- adaptiveAckRecoveryOkCount,
4534
- nowMs,
4535
- });
4536
- const reason = this.computeRecommendedAckTimeoutReason({
4256
+ const { timeoutMs, reason } = resolveBncrRuntimeAckTimeoutDecision({
4537
4257
  lateAckOkCount,
4538
4258
  recentAckTimeoutCount,
4539
4259
  lastLateAckPushLatencyMs,
4540
4260
  lastLateAckOkAt,
4541
4261
  adaptiveAckRecoveryOkCount,
4542
- recommendedAckTimeoutMs: timeoutMs,
4543
4262
  nowMs,
4263
+ defaultAckTimeoutMs: PUSH_ACK_TIMEOUT_MS,
4264
+ minAckTimeoutMs: RECOMMENDED_ACK_TIMEOUT_MIN_MS,
4265
+ maxAckTimeoutMs: RECOMMENDED_ACK_TIMEOUT_MAX_MS,
4266
+ lateAckObservationTtlMs: ADAPTIVE_ACK_TIMEOUT_OBSERVATION_TTL_MS,
4267
+ recoveryOkThreshold: ADAPTIVE_ACK_TIMEOUT_RECOVERY_OK_THRESHOLD,
4544
4268
  });
4545
4269
  this.maybeLogAdaptiveAckTimeout({
4546
4270
  accountId: acc,
@@ -4559,58 +4283,30 @@ class BncrBridgeRuntime {
4559
4283
  const lastLateAckPushLatencyMs = this.lastLateAckPushLatencyMsByAccount.get(acc) || null;
4560
4284
  const lastLateAckOkAt = this.lastLateAckOkByAccount.get(acc) || null;
4561
4285
  const nowMs = now();
4562
- const lastLateAckAgeMs =
4563
- typeof lastLateAckOkAt === 'number' && lastLateAckOkAt > 0
4564
- ? Math.max(0, nowMs - lastLateAckOkAt)
4565
- : null;
4566
- const lateAckObservationTtlMs = ADAPTIVE_ACK_TIMEOUT_OBSERVATION_TTL_MS;
4567
- const lateAckObservationExpired =
4568
- typeof lastLateAckAgeMs === 'number' && lastLateAckAgeMs > lateAckObservationTtlMs;
4569
4286
  const adaptiveAckRecoveryOkCount = this.getCounter(
4570
4287
  this.adaptiveAckRecoveryOkCountByAccount,
4571
4288
  acc,
4572
4289
  );
4573
- const adaptiveAckRecovered =
4574
- adaptiveAckRecoveryOkCount >= ADAPTIVE_ACK_TIMEOUT_RECOVERY_OK_THRESHOLD;
4575
- const recommendedAckTimeoutMs = this.computeRecommendedAckTimeoutMs({
4576
- lateAckOkCount,
4577
- recentAckTimeoutCount,
4578
- lastLateAckPushLatencyMs,
4579
- lastLateAckOkAt,
4580
- adaptiveAckRecoveryOkCount,
4581
- nowMs,
4582
- });
4583
- const currentAckTimeoutMs = this.resolveMessageAckTimeoutMs(acc);
4584
- return {
4290
+ return buildBncrRuntimeAckObservability({
4585
4291
  lastAckOkAt: this.lastAckOkByAccount.get(acc) || null,
4586
4292
  lastAckTimeoutAt: this.lastAckTimeoutByAccount.get(acc) || null,
4587
4293
  recentAckTimeoutCount,
4588
4294
  lateAckOkCount,
4589
4295
  lastLateAckOkAt,
4590
- lastLateAckAgeMs,
4591
- lateAckObservationTtlMs,
4592
- lateAckObservationExpired,
4593
4296
  adaptiveAckRecoveryOkCount,
4594
- adaptiveAckRecoveryOkThreshold: ADAPTIVE_ACK_TIMEOUT_RECOVERY_OK_THRESHOLD,
4595
- adaptiveAckRecovered,
4596
4297
  lastAckQueueLatencyMs: this.lastAckQueueLatencyMsByAccount.get(acc) || null,
4597
4298
  lastAckPushLatencyMs: this.lastAckPushLatencyMsByAccount.get(acc) || null,
4598
4299
  lastLateAckQueueLatencyMs: this.lastLateAckQueueLatencyMsByAccount.get(acc) || null,
4599
4300
  lastLateAckPushLatencyMs,
4600
4301
  adaptiveAckTimeoutEnabled: ADAPTIVE_ACK_TIMEOUT_DEFAULT_ENABLED,
4601
4302
  defaultAckTimeoutMs: PUSH_ACK_TIMEOUT_MS,
4602
- currentAckTimeoutMs,
4603
- recommendedAckTimeoutMs,
4604
- recommendedAckTimeoutReason: this.computeRecommendedAckTimeoutReason({
4605
- lateAckOkCount,
4606
- recentAckTimeoutCount,
4607
- lastLateAckPushLatencyMs,
4608
- lastLateAckOkAt,
4609
- adaptiveAckRecoveryOkCount,
4610
- recommendedAckTimeoutMs,
4611
- nowMs,
4612
- }),
4613
- };
4303
+ currentAckTimeoutMs: this.resolveMessageAckTimeoutMs(acc),
4304
+ minAckTimeoutMs: RECOMMENDED_ACK_TIMEOUT_MIN_MS,
4305
+ maxAckTimeoutMs: RECOMMENDED_ACK_TIMEOUT_MAX_MS,
4306
+ lateAckObservationTtlMs: ADAPTIVE_ACK_TIMEOUT_OBSERVATION_TTL_MS,
4307
+ recoveryOkThreshold: ADAPTIVE_ACK_TIMEOUT_RECOVERY_OK_THRESHOLD,
4308
+ nowMs,
4309
+ });
4614
4310
  }
4615
4311
 
4616
4312
  private buildRuntimeAckStrategy(ackObservability: Record<string, any>) {
@@ -4731,6 +4427,9 @@ class BncrBridgeRuntime {
4731
4427
  { debugOnly: true },
4732
4428
  );
4733
4429
  this.logOutboundSummary(entry);
4430
+ const accountId = normalizeAccountId(entry.accountId);
4431
+ this.incrementCounter(this.outboundEnqueueCountByAccount, accountId);
4432
+ this.lastOutboundEnqueueAtByAccount.set(accountId, now());
4734
4433
  this.outbox.set(entry.messageId, entry);
4735
4434
  this.scheduleSave();
4736
4435
  this.flushPushQueueBestEffort({ accountId: entry.accountId });
@@ -4752,12 +4451,14 @@ class BncrBridgeRuntime {
4752
4451
  entry: dead,
4753
4452
  maxEntries: MAX_DEAD_LETTER_ENTRIES,
4754
4453
  });
4454
+ this.incrementCounter(this.deadLetterSinceStartByAccount, dead.accountId);
4455
+ this.logDeadLetterSummary(dead.accountId, { source: 'move' });
4755
4456
  this.outbox.delete(entry.messageId);
4756
4457
  this.resolveMessageAck(entry.messageId, 'timeout');
4757
4458
  this.scheduleSave();
4758
4459
  }
4759
4460
 
4760
- private collectDue(accountId: string, maxBatch: number): Array<Record<string, unknown>> {
4461
+ collectDue(accountId: string, maxBatch: number): Array<Record<string, unknown>> {
4761
4462
  const key = normalizeAccountId(accountId);
4762
4463
  const result = collectDueOutboxEntries({
4763
4464
  outbox: this.outbox.values(),
@@ -4779,21 +4480,6 @@ class BncrBridgeRuntime {
4779
4480
  return result.duePayloads;
4780
4481
  }
4781
4482
 
4782
- private async payloadMediaToBase64(
4783
- mediaUrl: string,
4784
- mediaLocalRoots?: readonly string[],
4785
- ): Promise<{ mediaBase64: string; mimeType?: string; fileName?: string }> {
4786
- const loaded = await loadOpenClawWebMedia(this.api, mediaUrl, {
4787
- localRoots: mediaLocalRoots,
4788
- maxBytes: 20 * 1024 * 1024,
4789
- });
4790
- return {
4791
- mediaBase64: loaded.buffer.toString('base64'),
4792
- mimeType: loaded.contentType,
4793
- fileName: loaded.fileName,
4794
- };
4795
- }
4796
-
4797
4483
  private async loadOutboundTransferMedia(params: {
4798
4484
  mediaUrl: string;
4799
4485
  mediaLocalRoots?: readonly string[];
@@ -4826,14 +4512,7 @@ class BncrBridgeRuntime {
4826
4512
  ? this.resolveRecentInboundConnIds(args.accountId)
4827
4513
  : new Set<string>();
4828
4514
  const activeConnectionKey = this.activeConnectionByAccount.get(args.accountId) || null;
4829
- const accountConnections = Array.from(this.connections.values())
4830
- .filter((c) => c.accountId === args.accountId)
4831
- .map((c) => ({
4832
- connId: c.connId,
4833
- clientId: c.clientId,
4834
- connectedAt: c.connectedAt,
4835
- lastSeenAt: c.lastSeenAt,
4836
- }));
4515
+ const accountConnections = this.buildActiveConnectionDebugList(args.accountId);
4837
4516
 
4838
4517
  return {
4839
4518
  directConnIds,
@@ -5348,9 +5027,10 @@ class BncrBridgeRuntime {
5348
5027
  route: BncrRoute;
5349
5028
  payload: ReplyPayloadInput;
5350
5029
  mediaLocalRoots?: readonly string[];
5030
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
5351
5031
  }) {
5352
- const { accountId, sessionKey, route, payload, mediaLocalRoots } = params;
5353
- const normalized = normalizeReplyPayload(payload, { asString });
5032
+ const { accountId, sessionKey, route, payload, mediaLocalRoots, replyTargetPolicy } = params;
5033
+ const normalized = normalizeReplyPayload(payload, { asString }, { replyTargetPolicy });
5354
5034
 
5355
5035
  enqueueNormalizedReplyPayload(
5356
5036
  {
@@ -5486,9 +5166,7 @@ class BncrBridgeRuntime {
5486
5166
  pushEvent: BNCR_PUSH_EVENT,
5487
5167
  online: true,
5488
5168
  isPrimary: this.isPrimaryConnection(accountId, clientId),
5489
- activeConnections: this.activeConnectionCount(accountId),
5490
- pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
5491
- deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
5169
+ ...this.buildAccountQueueCounters(accountId),
5492
5170
  diagnostics: this.buildExtendedDiagnostics(accountId),
5493
5171
  runtimeFlags: this.buildRuntimeFlags(accountId),
5494
5172
  waiters: {
@@ -5582,9 +5260,7 @@ class BncrBridgeRuntime {
5582
5260
  accountId,
5583
5261
  ok: true,
5584
5262
  event: 'activity',
5585
- activeConnections: this.activeConnectionCount(accountId),
5586
- pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
5587
- deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
5263
+ ...this.buildAccountQueueCounters(accountId),
5588
5264
  now: now(),
5589
5265
  });
5590
5266
  this.flushPushQueueBestEffort({
@@ -5622,6 +5298,78 @@ class BncrBridgeRuntime {
5622
5298
  );
5623
5299
  };
5624
5300
 
5301
+ handleDeadLetterInspect = async ({ params, respond }: GatewayRequestHandlerOptions) => {
5302
+ const accountId = normalizeAccountId(asString(params?.accountId || BNCR_DEFAULT_ACCOUNT_ID));
5303
+ const reason = asString(params?.reason || '').trim() || null;
5304
+ const olderThan = parseDeadLetterOlderThan(params?.olderThan);
5305
+ const limit = parseDeadLetterLimit(params?.limit, 20);
5306
+ const offset = parseDeadLetterOffset(params?.offset, 0);
5307
+ const matches = this.filterDeadLetterEntries({ accountId, reason, olderThan })
5308
+ .slice()
5309
+ .sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0));
5310
+
5311
+ respond(true, {
5312
+ ok: true,
5313
+ accountId,
5314
+ filters: { reason, olderThan },
5315
+ total: matches.length,
5316
+ offset,
5317
+ limit,
5318
+ entries: matches
5319
+ .slice(offset, offset + limit)
5320
+ .map((entry) => summarizeDeadLetterEntry(entry)),
5321
+ summary: this.buildDeadLetterDiagnostics(accountId),
5322
+ now: now(),
5323
+ });
5324
+ };
5325
+
5326
+ handleDeadLetterPrune = async ({ params, respond }: GatewayRequestHandlerOptions) => {
5327
+ const accountId = normalizeAccountId(asString(params?.accountId || BNCR_DEFAULT_ACCOUNT_ID));
5328
+ const reason = asString(params?.reason || '').trim() || null;
5329
+ const olderThan = parseDeadLetterOlderThan(params?.olderThan);
5330
+ const limit = parseDeadLetterLimit(params?.limit, 100);
5331
+ const dryRun = params?.dryRun !== false;
5332
+ const hasDestructiveFilter = Boolean(reason || olderThan !== null);
5333
+ if (!dryRun && !hasDestructiveFilter) {
5334
+ respond(false, {
5335
+ ok: false,
5336
+ error: 'deadLetter-prune-requires-filter',
5337
+ message: 'dryRun=false requires at least one destructive filter: reason or olderThan',
5338
+ dryRun,
5339
+ accountId,
5340
+ filters: { reason, olderThan },
5341
+ summary: this.buildDeadLetterDiagnostics(accountId),
5342
+ now: now(),
5343
+ });
5344
+ return;
5345
+ }
5346
+ const matches = this.filterDeadLetterEntries({ accountId, reason, olderThan })
5347
+ .slice()
5348
+ .sort((a, b) => Number(a.createdAt || 0) - Number(b.createdAt || 0));
5349
+ const selected = matches.slice(0, limit);
5350
+ const selectedEntries = new Set(selected);
5351
+
5352
+ if (!dryRun && selectedEntries.size > 0) {
5353
+ this.deadLetter = this.deadLetter.filter((entry) => !selectedEntries.has(entry));
5354
+ this.scheduleSave();
5355
+ this.logDeadLetterSummary(accountId, { force: true, source: 'prune' });
5356
+ }
5357
+
5358
+ respond(true, {
5359
+ ok: true,
5360
+ dryRun,
5361
+ accountId,
5362
+ filters: { reason, olderThan },
5363
+ matched: matches.length,
5364
+ pruned: dryRun ? 0 : selected.length,
5365
+ wouldPrune: selected.length,
5366
+ limit,
5367
+ entries: selected.map((entry) => summarizeDeadLetterEntry(entry)),
5368
+ summary: this.buildDeadLetterDiagnostics(accountId),
5369
+ now: now(),
5370
+ });
5371
+ };
5372
+
5625
5373
  handleFileInit = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
5626
5374
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
5627
5375
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
@@ -6063,7 +5811,22 @@ class BncrBridgeRuntime {
6063
5811
  return;
6064
5812
  }
6065
5813
 
5814
+ if (!['init', 'chunk', 'complete', 'abort'].includes(stage)) {
5815
+ respond(false, { error: 'invalid file ack stage' });
5816
+ return;
5817
+ }
5818
+
6066
5819
  const st = this.fileSendTransfers.get(transferId);
5820
+ const fileAckWaiterKey = this.fileAckKey(
5821
+ transferId,
5822
+ stage,
5823
+ chunkIndex != null ? chunkIndex : undefined,
5824
+ );
5825
+ if (!st && !this.fileAckWaiters.has(fileAckWaiterKey)) {
5826
+ respond(false, { error: 'unknown transferId' });
5827
+ return;
5828
+ }
5829
+
6067
5830
  const staleKind =
6068
5831
  stage === 'init'
6069
5832
  ? 'file.init'
@@ -6194,24 +5957,7 @@ class BncrBridgeRuntime {
6194
5957
  // versus "scheduled retry" versus "ACK-driven continuation".
6195
5958
  await this.syncDebugFlag();
6196
5959
  const parsed = parseBncrInboundParams(params);
6197
- const {
6198
- accountId,
6199
- platform,
6200
- groupId,
6201
- userId,
6202
- sessionKeyfromroute,
6203
- route,
6204
- text,
6205
- msgType,
6206
- mediaBase64,
6207
- mediaPathFromTransfer,
6208
- mimeType,
6209
- fileName,
6210
- msgId,
6211
- dedupKey,
6212
- peer,
6213
- extracted,
6214
- } = parsed;
5960
+ const { accountId, platform, route, msgType, msgId, peer, extracted } = parsed;
6215
5961
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
6216
5962
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
6217
5963
  const outboundReady = (params as any)?.outboundReady === true;
@@ -6260,14 +6006,7 @@ class BncrBridgeRuntime {
6260
6006
  onlineAfterSeen: this.isOnline(accountId),
6261
6007
  recentInboundReachable: this.hasRecentInboundReachability(accountId),
6262
6008
  activeConnectionKey: this.activeConnectionByAccount.get(accountId) || null,
6263
- activeConnections: Array.from(this.connections.values())
6264
- .filter((c) => c.accountId === accountId)
6265
- .map((c) => ({
6266
- connId: c.connId,
6267
- clientId: c.clientId,
6268
- connectedAt: c.connectedAt,
6269
- lastSeenAt: c.lastSeenAt,
6270
- })),
6009
+ activeConnections: this.buildActiveConnectionDebugList(accountId),
6271
6010
  }),
6272
6011
  )}`,
6273
6012
  { debugOnly: true },
@@ -6375,7 +6114,7 @@ class BncrBridgeRuntime {
6375
6114
  }) {
6376
6115
  this.logInfo(
6377
6116
  'outbound',
6378
- `send-entry:${args.kind} ${JSON.stringify({
6117
+ buildBncrDebugJsonMessage(`send-entry:${args.kind}`, {
6379
6118
  accountId: args.accountId,
6380
6119
  to: args.to,
6381
6120
  text: args.payload.text,
@@ -6391,7 +6130,7 @@ class BncrBridgeRuntime {
6391
6130
  threadId: args.ctx?.threadId,
6392
6131
  replyToId: args.ctx?.replyToId,
6393
6132
  },
6394
- })}`,
6133
+ }),
6395
6134
  { debugOnly: true },
6396
6135
  );
6397
6136
  }