@xmoxmo/bncr 0.3.4 → 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 (38) hide show
  1. package/dist/index.js +7 -3
  2. package/index.ts +6 -0
  3. package/openclaw.plugin.json +21 -0
  4. package/package.json +1 -1
  5. package/scripts/check-pack.mjs +97 -17
  6. package/scripts/check-register-drift.mjs +91 -65
  7. package/scripts/selfcheck.mjs +79 -3
  8. package/src/channel.ts +477 -635
  9. package/src/core/connection-capability.ts +2 -2
  10. package/src/core/connection-reachability.ts +106 -0
  11. package/src/core/dead-letter-diagnostics.ts +91 -0
  12. package/src/core/diagnostic-counters.ts +61 -0
  13. package/src/core/diagnostics.ts +9 -5
  14. package/src/core/downlink-health.ts +12 -7
  15. package/src/core/extended-diagnostics.ts +2 -0
  16. package/src/core/logging.ts +98 -0
  17. package/src/core/outbox-entry-builders.ts +13 -2
  18. package/src/core/persisted-outbox-entry.ts +53 -0
  19. package/src/core/probe.ts +33 -13
  20. package/src/core/register-trace.ts +48 -0
  21. package/src/core/status-meta.ts +77 -0
  22. package/src/core/status.ts +50 -57
  23. package/src/messaging/inbound/commands.ts +25 -86
  24. package/src/messaging/inbound/dispatch.ts +9 -36
  25. package/src/messaging/inbound/last-route.ts +46 -0
  26. package/src/messaging/inbound/native-command.ts +49 -0
  27. package/src/messaging/inbound/native-reply-delivery.ts +43 -0
  28. package/src/messaging/outbound/diagnostics.ts +221 -2
  29. package/src/messaging/outbound/reply-enqueue.ts +24 -1
  30. package/src/messaging/outbound/reply-target-policy.ts +4 -1
  31. package/src/messaging/outbound/send-params.ts +56 -0
  32. package/src/openclaw/runtime-surface.ts +29 -0
  33. package/src/plugin/gateway-methods.ts +2 -0
  34. package/src/plugin/status.ts +10 -4
  35. package/src/runtime/outbound-ack-timeout.ts +73 -0
  36. package/src/runtime/register-trace-runtime.ts +102 -0
  37. package/src/runtime/status-snapshots.ts +7 -3
  38. package/src/runtime/status-worker.ts +70 -11
package/src/channel.ts CHANGED
@@ -23,9 +23,23 @@ import {
23
23
  getRevalidatedAttemptReason,
24
24
  hasAlternativeLiveConnection as hasAlternativeLiveConnectionFromRuntime,
25
25
  hasRecentInboundReachability as hasRecentInboundReachabilityFromRuntime,
26
+ isEligibleOutboundPushConnection,
26
27
  isRecentlyReachableConn as isRecentlyReachableConnFromRuntime,
27
28
  resolveRecentInboundConnIds as resolveRecentInboundConnIdsFromRuntime,
29
+ selectOrderedOutboundPushConnections,
28
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';
29
43
  import { buildDiagnosticsPayload } from './core/diagnostics.ts';
30
44
  import { buildDownlinkHealth as buildDownlinkHealthFromRuntime } from './core/downlink-health.ts';
31
45
  import { buildExtendedDiagnostics as buildExtendedDiagnosticsFromRuntime } from './core/extended-diagnostics.ts';
@@ -40,7 +54,12 @@ import {
40
54
  matchesTransferOwner as matchesTransferOwnerFromRuntime,
41
55
  observeLeaseState,
42
56
  } from './core/lease-state.ts';
43
- import { emitBncrLog, emitBncrLogLine } from './core/logging.ts';
57
+ import {
58
+ buildBncrDebugJsonMessage,
59
+ emitBncrLog,
60
+ emitBncrLogLine,
61
+ summarizeBncrTextPreview,
62
+ } from './core/logging.ts';
44
63
  import { buildOutboxEnqueueDebugInfo } from './core/outbox-enqueue.ts';
45
64
  import {
46
65
  buildFileTransferOutboxEntry as buildFileTransferOutboxEntryFromRuntime,
@@ -75,12 +94,11 @@ import {
75
94
  buildTextPushRouteSelectArgs,
76
95
  buildTextPushSuccessArgs,
77
96
  } from './core/outbox-text-push-success.ts';
97
+ import { normalizePersistedOutboxEntry as normalizePersistedOutboxEntryFromRuntime } from './core/persisted-outbox-entry.ts';
78
98
  import { resolveBncrChannelPolicy, resolveBncrConfigWarnings } from './core/policy.ts';
79
99
  import {
80
- appendBoundedRegisterTrace,
81
- buildRegisterDriftSnapshot,
82
- buildRegisterTraceEntry,
83
- buildRegisterTraceSummary as buildRegisterTraceSummaryFromEntries,
100
+ dumpRegisterDriftSnapshot,
101
+ normalizeRegisterDriftSnapshot,
84
102
  } from './core/register-trace.ts';
85
103
  import {
86
104
  buildAccountRuntimeSnapshot,
@@ -105,18 +123,21 @@ import { checkBncrMessageGate } from './messaging/inbound/gate.ts';
105
123
  import { parseBncrInboundParams } from './messaging/inbound/parse.ts';
106
124
  import {
107
125
  buildEnqueueFromReplyDebugInfo,
126
+ buildExtendedOutboundDiagnostics,
108
127
  buildFlushDebugInfo,
109
128
  buildOutboxAckDebugInfo,
110
129
  buildOutboxDrainSkipDebugInfo,
111
130
  buildOutboxDrainStuckDebugInfo,
112
131
  buildOutboxPushOkDebugInfo,
113
132
  buildOutboxPushSkipDebugInfo,
133
+ buildOutboxQueueDiagnostics,
114
134
  buildOutboxRouteSelectDebugInfo,
115
135
  buildOutboxScheduleDebugInfo,
116
136
  buildPushFailureDebugInfo,
117
137
  buildRetryRerouteDebugInfo,
118
138
  } from './messaging/outbound/diagnostics.ts';
119
139
  import { buildBncrMediaOutboundFrame } from './messaging/outbound/media.ts';
140
+ import { normalizeBncrSendParams } from './messaging/outbound/send-params.ts';
120
141
  import {
121
142
  getOpenClawRuntimeConfig,
122
143
  getOpenClawRuntimeConfigOrDefault,
@@ -127,14 +148,18 @@ import {
127
148
  saveOpenClawChannelMediaBuffer,
128
149
  } from './openclaw/media-runtime.ts';
129
150
  import { resolveOpenClawAgentRoute } from './openclaw/routing-runtime.ts';
151
+ import { buildOpenClawChannelRuntimeSurfaceDiagnostics } from './openclaw/runtime-surface.ts';
130
152
  import {
131
153
  extractOpenClawToolSend,
132
154
  openClawJsonResult,
133
- readOpenClawBooleanParam,
134
155
  readOpenClawJsonFileWithFallback,
135
- readOpenClawStringParam,
136
156
  writeOpenClawJsonFileAtomically,
137
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';
138
163
 
139
164
  function buildInboundAcceptedLifecycleDebugInfo(args: {
140
165
  stage: 'accepted';
@@ -304,6 +329,7 @@ import {
304
329
  hasReplyMediaEntries,
305
330
  type NormalizedReplyPayload,
306
331
  normalizeReplyPayload,
332
+ type OutboundReplyTargetPolicy,
307
333
  type ReplyMediaEntriesParams,
308
334
  type ReplyPayloadInput,
309
335
  } from './messaging/outbound/reply-enqueue.ts';
@@ -325,9 +351,9 @@ import { BNCR_SETUP_SURFACE } from './plugin/setup.ts';
325
351
  import { createBncrStatusSurface } from './plugin/status.ts';
326
352
  import { shouldEmitDedupLog as shouldEmitDedupLogFromRuntime } from './runtime/log-dedupe.ts';
327
353
  import {
354
+ buildBncrRuntimeAckObservability,
328
355
  buildBncrRuntimeAckStrategy,
329
- computeBncrRecommendedAckTimeoutMs,
330
- computeBncrRecommendedAckTimeoutReason,
356
+ resolveBncrRuntimeAckTimeoutDecision,
331
357
  } from './runtime/outbound-ack-timeout.ts';
332
358
  import {
333
359
  buildBncrRuntimeFlags,
@@ -496,56 +522,6 @@ type PersistedState = {
496
522
  } | null;
497
523
  };
498
524
 
499
- type NormalizedBncrSendParams = {
500
- to: string;
501
- accountId: string;
502
- message: string;
503
- caption: string;
504
- mediaUrl?: string;
505
- asVoice: boolean;
506
- audioAsVoice: boolean;
507
- };
508
-
509
- function normalizeBncrSendParams(input: {
510
- params: unknown;
511
- accountId: string;
512
- }): NormalizedBncrSendParams {
513
- const paramsObj = isPlainObject(input.params) ? input.params : {};
514
- const to = readOpenClawStringParam(paramsObj, 'to', { required: true });
515
- const resolvedAccountId = normalizeAccountId(
516
- readOpenClawStringParam(paramsObj, 'accountId') ?? input.accountId,
517
- );
518
-
519
- const message = readOpenClawStringParam(paramsObj, 'message', { allowEmpty: true }) ?? '';
520
- const caption = readOpenClawStringParam(paramsObj, 'caption', { allowEmpty: true }) ?? '';
521
- const mediaUrl =
522
- readOpenClawStringParam(paramsObj, 'media', { trim: false }) ??
523
- readOpenClawStringParam(paramsObj, 'path', { trim: false }) ??
524
- readOpenClawStringParam(paramsObj, 'filePath', { trim: false }) ??
525
- readOpenClawStringParam(paramsObj, 'mediaUrl', { trim: false });
526
- const asVoice = readOpenClawBooleanParam(paramsObj, 'asVoice') ?? false;
527
- const audioAsVoice = readOpenClawBooleanParam(paramsObj, 'audioAsVoice') ?? false;
528
-
529
- if (asVoice && !mediaUrl) throw new Error('send voice requires media path');
530
-
531
- const normalizedMessage = mediaUrl ? '' : message || caption || '';
532
- const normalizedCaption = mediaUrl ? caption || message || '' : '';
533
-
534
- if (!normalizedMessage.trim() && !normalizedCaption.trim() && !mediaUrl) {
535
- throw new Error('send requires message or media');
536
- }
537
-
538
- return {
539
- to,
540
- accountId: resolvedAccountId,
541
- message: normalizedMessage,
542
- caption: normalizedCaption,
543
- mediaUrl: mediaUrl || undefined,
544
- asVoice,
545
- audioAsVoice,
546
- };
547
- }
548
-
549
525
  function now() {
550
526
  return Date.now();
551
527
  }
@@ -561,12 +537,6 @@ function finiteNumberOr(value: unknown, fallback: number): number {
561
537
  return Number.isFinite(n) ? n : fallback;
562
538
  }
563
539
 
564
- function optionalFiniteNumber(value: unknown): number | undefined {
565
- if (value == null || value === '') return undefined;
566
- const n = Number(value);
567
- return Number.isFinite(n) ? n : undefined;
568
- }
569
-
570
540
  function finiteNonNegativeNumberOrNull(value: unknown): number | null {
571
541
  const n = Number(value);
572
542
  return Number.isFinite(n) && n >= 0 ? n : null;
@@ -770,6 +740,7 @@ class BncrBridgeRuntime {
770
740
  private prePushGuardSkipCountByAccount = new Map<string, number>();
771
741
  private lastPrePushGuardSkipAtByAccount = new Map<string, number>();
772
742
  private lastPrePushGuardSkipReasonByAccount = new Map<string, string>();
743
+ private deadLetterSinceStartByAccount = new Map<string, number>();
773
744
  private messageAckWaiters = new Map<
774
745
  // Refactor boundary note (message ACK runtime):
775
746
  // These waiters are part of the outbound message-ack lifecycle, not just a utility map.
@@ -832,17 +803,13 @@ class BncrBridgeRuntime {
832
803
  emitBncrLog('error', scope, message, options, () => this.isDebugEnabled());
833
804
  }
834
805
 
835
- private buildDebugJsonMessage(event: string, payload: Record<string, unknown>) {
836
- return `${event} ${JSON.stringify(payload)}`;
837
- }
838
-
839
806
  private logInfoJson(
840
807
  scope: string | undefined,
841
808
  event: string,
842
809
  payload: Record<string, unknown>,
843
810
  options?: { debugOnly?: boolean },
844
811
  ) {
845
- this.logInfo(scope, this.buildDebugJsonMessage(event, payload), options);
812
+ this.logInfo(scope, buildBncrDebugJsonMessage(event, payload), options);
846
813
  }
847
814
 
848
815
  private shouldEmitDedupLog(key: string, sig: string, windowMs = 5 * 60 * 1000) {
@@ -875,12 +842,7 @@ class BncrBridgeRuntime {
875
842
  }
876
843
 
877
844
  private summarizeTextPreview(raw: string, limit = 8) {
878
- const compact = asString(raw || '')
879
- .replace(/\s+/g, ' ')
880
- .trim();
881
- if (!compact) return '-';
882
- const chars = Array.from(compact);
883
- return chars.length > limit ? `${chars.slice(0, Math.max(1, limit)).join('')}…` : compact;
845
+ return summarizeBncrTextPreview(raw, limit);
884
846
  }
885
847
 
886
848
  private summarizeScope(route: BncrRoute) {
@@ -953,25 +915,39 @@ class BncrBridgeRuntime {
953
915
  clearAllBncrStatusWorkers(this.buildStatusWorkerRuntime(), reason);
954
916
  }
955
917
 
956
- private captureDriftSnapshot(
957
- summary: ReturnType<BncrBridgeRuntime['buildRegisterTraceSummary']>,
958
- ) {
959
- this.lastDriftSnapshot = buildRegisterDriftSnapshot({
960
- capturedAt: now(),
918
+ private getRegisterTraceRuntimeState(): RegisterTraceRuntimeState {
919
+ return {
961
920
  registerCount: this.registerCount,
962
921
  apiGeneration: this.apiGeneration,
963
- summary,
964
- apiInstanceId: this.lastApiInstanceId,
965
- registryFingerprint: this.lastRegistryFingerprint,
966
- traceRecent: this.registerTraceRecent,
967
- });
968
- 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;
969
946
  }
970
947
 
971
948
  private buildRegisterTraceSummary() {
972
- return buildRegisterTraceSummaryFromEntries({
973
- traceRecent: this.registerTraceRecent,
974
- firstRegisterAt: this.firstRegisterAt,
949
+ return buildRegisterTraceRuntimeSummary({
950
+ state: this.getRegisterTraceRuntimeState(),
975
951
  warmupWindowMs: REGISTER_WARMUP_WINDOW_MS,
976
952
  });
977
953
  }
@@ -984,43 +960,25 @@ class BncrBridgeRuntime {
984
960
  registryFingerprint?: string;
985
961
  }) {
986
962
  const ts = now();
987
- this.registerCount += 1;
988
- if (this.firstRegisterAt == null) this.firstRegisterAt = ts;
989
- this.lastRegisterAt = ts;
990
- if (meta.apiRebound) {
991
- this.apiGeneration += 1;
992
- this.lastApiRebindAt = ts;
993
- } else if (this.registerCount === 1 && this.apiGeneration === 0) {
994
- this.apiGeneration = 1;
995
- }
996
- if (meta.source) this.pluginSource = meta.source;
997
- if (meta.pluginVersion) this.pluginVersion = meta.pluginVersion;
998
- if (meta.apiInstanceId) this.lastApiInstanceId = meta.apiInstanceId;
999
- if (meta.registryFingerprint) this.lastRegistryFingerprint = meta.registryFingerprint;
1000
-
1001
963
  const stack = String(new Error().stack || '')
1002
964
  .split('\n')
1003
965
  .slice(2, 7)
1004
966
  .map((line) => line.trim())
1005
967
  .filter(Boolean)
1006
968
  .join(' <- ');
1007
- const trace = buildRegisterTraceEntry({
969
+ const state = this.getRegisterTraceRuntimeState();
970
+ const { trace, capturedDriftSnapshot } = noteRegisterTraceRuntime({
971
+ state,
972
+ meta,
1008
973
  ts,
974
+ stack,
1009
975
  bridgeId: this.bridgeId,
1010
976
  gatewayPid: this.gatewayPid,
1011
- registerCount: this.registerCount,
1012
- apiGeneration: this.apiGeneration,
1013
- apiRebound: meta.apiRebound === true,
1014
- apiInstanceId: this.lastApiInstanceId,
1015
- registryFingerprint: this.lastRegistryFingerprint,
1016
- source: this.pluginSource,
1017
- pluginVersion: this.pluginVersion,
1018
- stack,
977
+ warmupWindowMs: REGISTER_WARMUP_WINDOW_MS,
978
+ maxTraceEntries: 12,
1019
979
  });
1020
- appendBoundedRegisterTrace(this.registerTraceRecent, trace, 12);
1021
-
1022
- const summary = this.buildRegisterTraceSummary();
1023
- if (summary.postWarmupRegisterCount > 0) this.captureDriftSnapshot(summary);
980
+ this.applyRegisterTraceRuntimeState(state);
981
+ if (capturedDriftSnapshot) this.scheduleSave();
1024
982
 
1025
983
  this.logInfo('debug', `register-trace ${JSON.stringify(trace)}`, { debugOnly: true });
1026
984
  }
@@ -1121,25 +1079,18 @@ class BncrBridgeRuntime {
1121
1079
  }
1122
1080
 
1123
1081
  private buildRuntimeSurfaceDiagnostics() {
1124
- const channelRuntime = (this.api as any)?.runtime?.channel;
1125
- const surfaces = {
1126
- inbound: Boolean(channelRuntime?.inbound),
1127
- media: Boolean(channelRuntime?.media),
1128
- reply: Boolean(channelRuntime?.reply),
1129
- routing: Boolean(channelRuntime?.routing),
1130
- session: Boolean(channelRuntime?.session),
1131
- };
1132
- return {
1133
- channel: surfaces,
1134
- missing: Object.entries(surfaces)
1135
- .filter(([, present]) => !present)
1136
- .map(([name]) => name),
1137
- };
1082
+ return buildOpenClawChannelRuntimeSurfaceDiagnostics(this.api);
1138
1083
  }
1139
1084
 
1140
1085
  private buildExtendedDiagnostics(accountId: string) {
1141
1086
  const acc = normalizeAccountId(accountId);
1142
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);
1143
1094
  return buildExtendedDiagnosticsFromRuntime({
1144
1095
  diagnostics,
1145
1096
  runtimeSurface: this.buildRuntimeSurfaceDiagnostics(),
@@ -1179,16 +1130,19 @@ class BncrBridgeRuntime {
1179
1130
  isPrimary: entry.isPrimary,
1180
1131
  })),
1181
1132
  },
1182
- outbound: {
1183
- pending: Array.from(this.outbox.values()).filter((entry) => entry.accountId === acc).length,
1133
+ outbound: buildExtendedOutboundDiagnostics({
1134
+ outbox: outboxDiagnostics,
1184
1135
  enqueueCount: this.getCounter(this.outboundEnqueueCountByAccount, acc),
1185
1136
  lastEnqueueAt: this.lastOutboundEnqueueAtByAccount.get(acc) || null,
1186
- prePushGuardSkipCount: this.getCounter(this.prePushGuardSkipCountByAccount, acc),
1187
- lastPrePushGuardSkipAt: this.lastPrePushGuardSkipAtByAccount.get(acc) || null,
1188
- lastPrePushGuardSkipReason: this.lastPrePushGuardSkipReasonByAccount.get(acc) || null,
1189
- hasGatewayContext: Boolean(this.gatewayContext),
1137
+ prePushGuardSkipCount,
1138
+ lastPrePushGuardSkipAt,
1139
+ lastPrePushGuardSkipReason,
1140
+ hasGatewayContext,
1190
1141
  lastGatewayContextAt: this.lastGatewayContextAt,
1191
- },
1142
+ ackObservability,
1143
+ nowMs: now(),
1144
+ }),
1145
+ deadLetterSummary: this.buildDeadLetterDiagnostics(acc),
1192
1146
  protocol: {
1193
1147
  bridgeVersion: BRIDGE_VERSION,
1194
1148
  protocolVersion: 2,
@@ -1305,6 +1259,64 @@ class BncrBridgeRuntime {
1305
1259
  return map.get(normalizeAccountId(accountId)) || 0;
1306
1260
  }
1307
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
+
1308
1320
  private async refreshDebugFlagFromConfig(options?: { forceLog?: boolean }) {
1309
1321
  try {
1310
1322
  const cfg = getOpenClawRuntimeConfig(this.api);
@@ -1380,45 +1392,23 @@ class BncrBridgeRuntime {
1380
1392
  }
1381
1393
 
1382
1394
  private countInvalidOutboxSessionKeys(accountId: string): number {
1383
- const acc = normalizeAccountId(accountId);
1384
- let count = 0;
1385
- for (const entry of this.outbox.values()) {
1386
- if (entry.accountId !== acc) continue;
1387
- if (!parseStrictBncrSessionKey(entry.sessionKey)) count += 1;
1388
- }
1389
- return count;
1395
+ return countInvalidOutboxSessionKeysFromRuntime({
1396
+ accountId,
1397
+ outboxEntries: this.outbox.values(),
1398
+ });
1390
1399
  }
1391
1400
 
1392
1401
  private countLegacyAccountResidue(accountId: string): number {
1393
- const acc = normalizeAccountId(accountId);
1394
- const mismatched = (raw?: string | null) =>
1395
- asString(raw || '').trim() && normalizeAccountId(raw) !== acc;
1396
-
1397
- let count = 0;
1398
-
1399
- for (const entry of this.outbox.values()) {
1400
- if (mismatched(entry.accountId)) count += 1;
1401
- }
1402
- for (const entry of this.deadLetter) {
1403
- if (mismatched(entry.accountId)) count += 1;
1404
- }
1405
- for (const info of this.sessionRoutes.values()) {
1406
- if (mismatched(info.accountId)) count += 1;
1407
- }
1408
- for (const key of this.lastSessionByAccount.keys()) {
1409
- if (mismatched(key)) count += 1;
1410
- }
1411
- for (const key of this.lastActivityByAccount.keys()) {
1412
- if (mismatched(key)) count += 1;
1413
- }
1414
- for (const key of this.lastInboundByAccount.keys()) {
1415
- if (mismatched(key)) count += 1;
1416
- }
1417
- for (const key of this.lastOutboundByAccount.keys()) {
1418
- if (mismatched(key)) count += 1;
1419
- }
1420
-
1421
- 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
+ });
1422
1412
  }
1423
1413
 
1424
1414
  private buildIntegratedDiagnostics(accountId: string) {
@@ -1447,86 +1437,71 @@ class BncrBridgeRuntime {
1447
1437
  });
1448
1438
  }
1449
1439
 
1450
- private async loadState() {
1451
- if (!this.statePath) return;
1452
- const loaded = await readOpenClawJsonFileWithFallback(this.statePath, {
1453
- outbox: [],
1454
- deadLetter: [],
1455
- sessionRoutes: [],
1440
+ private normalizePersistedOutboxEntry(entry: any): OutboxEntry | null {
1441
+ return normalizePersistedOutboxEntryFromRuntime({
1442
+ entry,
1443
+ canonicalAgentId: this.canonicalAgentId,
1444
+ now,
1456
1445
  });
1457
- const data = loaded.value as PersistedState;
1458
-
1459
- this.outbox.clear();
1460
- for (const entry of data.outbox || []) {
1461
- if (!entry?.messageId) continue;
1462
- const accountId = normalizeAccountId(entry.accountId);
1463
- const sessionKey = asString(entry.sessionKey || '').trim();
1464
- const normalized = normalizeStoredSessionKey(sessionKey, this.canonicalAgentId);
1465
- if (!normalized) continue;
1466
-
1467
- const route = parseRouteLike(entry.route) || normalized.route;
1468
- const payload =
1469
- entry.payload && typeof entry.payload === 'object' ? { ...entry.payload } : {};
1470
- (payload as any).sessionKey = normalized.sessionKey;
1471
- (payload as any).platform = route.platform;
1472
- (payload as any).groupId = route.groupId;
1473
- (payload as any).userId = route.userId;
1474
-
1475
- const migratedEntry: OutboxEntry = {
1476
- ...entry,
1477
- accountId,
1478
- sessionKey: normalized.sessionKey,
1479
- route,
1480
- payload,
1481
- createdAt: finiteNumberOr(entry.createdAt, now()),
1482
- retryCount: finiteNumberOr(entry.retryCount, 0),
1483
- nextAttemptAt: finiteNumberOr(entry.nextAttemptAt, now()),
1484
- lastAttemptAt: optionalFiniteNumber(entry.lastAttemptAt),
1485
- lastError: entry.lastError ? asString(entry.lastError) : undefined,
1486
- };
1446
+ }
1487
1447
 
1488
- 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);
1489
1456
  }
1457
+ }
1490
1458
 
1491
- this.deadLetter = [];
1492
- const persistedDeadLetter = Array.isArray(data.deadLetter)
1493
- ? data.deadLetter.slice(-MAX_DEAD_LETTER_ENTRIES)
1494
- : [];
1495
- for (const entry of persistedDeadLetter) {
1496
- if (!entry?.messageId) continue;
1497
- const accountId = normalizeAccountId(entry.accountId);
1498
- const sessionKey = asString(entry.sessionKey || '').trim();
1499
- const normalized = normalizeStoredSessionKey(sessionKey, this.canonicalAgentId);
1500
- 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
+ }
1501
1467
 
1502
- const route = parseRouteLike(entry.route) || normalized.route;
1503
- const payload =
1504
- entry.payload && typeof entry.payload === 'object' ? { ...entry.payload } : {};
1505
- (payload as any).sessionKey = normalized.sessionKey;
1506
- (payload as any).platform = route.platform;
1507
- (payload as any).groupId = route.groupId;
1508
- (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;
1509
1479
 
1510
- this.deadLetter.push({
1511
- ...entry,
1512
- accountId,
1480
+ this.lastSessionByAccount.set(accountId, {
1513
1481
  sessionKey: normalized.sessionKey,
1514
- route,
1515
- payload,
1516
- createdAt: finiteNumberOr(entry.createdAt, now()),
1517
- retryCount: finiteNumberOr(entry.retryCount, 0),
1518
- nextAttemptAt: finiteNumberOr(entry.nextAttemptAt, now()),
1519
- lastAttemptAt: optionalFiniteNumber(entry.lastAttemptAt),
1520
- lastError: entry.lastError ? asString(entry.lastError) : undefined,
1482
+ // 展示统一为 Bncr-platform:group:user
1483
+ scope: formatDisplayScope(normalized.route),
1484
+ updatedAt,
1521
1485
  });
1522
1486
  }
1487
+ }
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
+ }
1523
1499
 
1500
+ private loadPersistedSessionRoutes(persisted: unknown): void {
1524
1501
  this.sessionRoutes.clear();
1525
1502
  this.routeAliases.clear();
1526
- const persistedSessionRoutes = Array.isArray(data.sessionRoutes)
1527
- ? data.sessionRoutes.slice(-MAX_SESSION_ROUTE_ENTRIES)
1528
- : [];
1529
- for (const item of persistedSessionRoutes) {
1503
+ const items = Array.isArray(persisted) ? persisted.slice(-MAX_SESSION_ROUTE_ENTRIES) : [];
1504
+ for (const item of items) {
1530
1505
  const normalized = normalizeStoredSessionKey(
1531
1506
  asString(item?.sessionKey || ''),
1532
1507
  this.canonicalAgentId,
@@ -1536,186 +1511,101 @@ class BncrBridgeRuntime {
1536
1511
  const route = parseRouteLike(item?.route) || normalized.route;
1537
1512
  const accountId = normalizeAccountId(item?.accountId);
1538
1513
  const updatedAt = finiteNumberOr(item?.updatedAt, now());
1539
-
1540
- const info = {
1541
- accountId,
1542
- route,
1543
- updatedAt,
1544
- };
1514
+ const info = { accountId, route, updatedAt };
1545
1515
 
1546
1516
  this.sessionRoutes.set(normalized.sessionKey, info);
1547
1517
  this.routeAliases.set(routeKey(accountId, route), info);
1548
1518
  }
1519
+ }
1549
1520
 
1550
- this.lastSessionByAccount.clear();
1551
- const persistedLastSessionByAccount = Array.isArray(data.lastSessionByAccount)
1552
- ? data.lastSessionByAccount.slice(-MAX_ACCOUNT_ACTIVITY_ENTRIES)
1553
- : [];
1554
- for (const item of persistedLastSessionByAccount) {
1555
- const accountId = normalizeAccountId(item?.accountId);
1556
- const normalized = normalizeStoredSessionKey(
1557
- asString(item?.sessionKey || ''),
1558
- this.canonicalAgentId,
1559
- );
1560
- const updatedAt = finiteNumberOr(item?.updatedAt, 0);
1561
- 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
+ }
1562
1531
 
1563
- this.lastSessionByAccount.set(accountId, {
1564
- sessionKey: normalized.sessionKey,
1565
- // 展示统一为 Bncr-platform:group:user
1566
- scope: formatDisplayScope(normalized.route),
1567
- updatedAt,
1568
- });
1569
- }
1532
+ private backfillAccountActivityFromSessionRoutes(): void {
1533
+ if (this.lastSessionByAccount.size > 0 || this.sessionRoutes.size === 0) return;
1570
1534
 
1571
- this.lastActivityByAccount.clear();
1572
- const persistedLastActivityByAccount = Array.isArray(data.lastActivityByAccount)
1573
- ? data.lastActivityByAccount.slice(-MAX_ACCOUNT_ACTIVITY_ENTRIES)
1574
- : [];
1575
- for (const item of persistedLastActivityByAccount) {
1576
- const accountId = normalizeAccountId(item?.accountId);
1577
- 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);
1578
1538
  if (updatedAt <= 0) continue;
1579
- 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);
1580
1555
  }
1556
+ }
1581
1557
 
1582
- this.lastInboundByAccount.clear();
1583
- const persistedLastInboundByAccount = Array.isArray(data.lastInboundByAccount)
1584
- ? data.lastInboundByAccount.slice(-MAX_ACCOUNT_ACTIVITY_ENTRIES)
1585
- : [];
1586
- for (const item of persistedLastInboundByAccount) {
1587
- const accountId = normalizeAccountId(item?.accountId);
1588
- const updatedAt = finiteNumberOr(item?.updatedAt, 0);
1589
- if (updatedAt <= 0) continue;
1590
- 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);
1591
1572
  }
1592
1573
 
1593
- this.lastOutboundByAccount.clear();
1594
- const persistedLastOutboundByAccount = Array.isArray(data.lastOutboundByAccount)
1595
- ? 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)
1596
1577
  : [];
1597
- for (const item of persistedLastOutboundByAccount) {
1598
- const accountId = normalizeAccountId(item?.accountId);
1599
- const updatedAt = finiteNumberOr(item?.updatedAt, 0);
1600
- if (updatedAt <= 0) continue;
1601
- 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);
1602
1582
  }
1603
1583
 
1604
- this.lastDriftSnapshot =
1605
- data.lastDriftSnapshot && typeof data.lastDriftSnapshot === 'object'
1606
- ? {
1607
- capturedAt: finiteNumberOr((data.lastDriftSnapshot as any).capturedAt, 0),
1608
- registerCount: Number.isFinite(Number((data.lastDriftSnapshot as any).registerCount))
1609
- ? Number((data.lastDriftSnapshot as any).registerCount)
1610
- : null,
1611
- apiGeneration: Number.isFinite(Number((data.lastDriftSnapshot as any).apiGeneration))
1612
- ? Number((data.lastDriftSnapshot as any).apiGeneration)
1613
- : null,
1614
- postWarmupRegisterCount: Number.isFinite(
1615
- Number((data.lastDriftSnapshot as any).postWarmupRegisterCount),
1616
- )
1617
- ? Number((data.lastDriftSnapshot as any).postWarmupRegisterCount)
1618
- : null,
1619
- apiInstanceId:
1620
- asString((data.lastDriftSnapshot as any).apiInstanceId || '').trim() || null,
1621
- registryFingerprint:
1622
- asString((data.lastDriftSnapshot as any).registryFingerprint || '').trim() || null,
1623
- dominantBucket:
1624
- asString((data.lastDriftSnapshot as any).dominantBucket || '').trim() || null,
1625
- sourceBuckets:
1626
- (data.lastDriftSnapshot as any).sourceBuckets &&
1627
- typeof (data.lastDriftSnapshot as any).sourceBuckets === 'object'
1628
- ? { ...((data.lastDriftSnapshot as any).sourceBuckets as Record<string, number>) }
1629
- : {},
1630
- traceWindowSize: finiteNumberOr((data.lastDriftSnapshot as any).traceWindowSize, 0),
1631
- traceRecent: Array.isArray((data.lastDriftSnapshot as any).traceRecent)
1632
- ? [...((data.lastDriftSnapshot as any).traceRecent as Array<Record<string, unknown>>)]
1633
- : [],
1634
- }
1635
- : null;
1584
+ this.loadPersistedSessionRoutes(data.sessionRoutes);
1636
1585
 
1637
- // 兼容旧状态文件:若尚未持久化 lastSession*/lastActivity*,从 sessionRoutes 回填。
1638
- if (this.lastSessionByAccount.size === 0 && this.sessionRoutes.size > 0) {
1639
- for (const [sessionKey, info] of this.sessionRoutes.entries()) {
1640
- const acc = normalizeAccountId(info.accountId);
1641
- const updatedAt = finiteNumberOr(info.updatedAt, 0);
1642
- if (updatedAt <= 0) continue;
1643
-
1644
- const current = this.lastSessionByAccount.get(acc);
1645
- if (!current || updatedAt >= current.updatedAt) {
1646
- this.lastSessionByAccount.set(acc, {
1647
- sessionKey,
1648
- // 回填时统一展示为 Bncr-platform:group:user
1649
- scope: formatDisplayScope(info.route),
1650
- updatedAt,
1651
- });
1652
- }
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);
1653
1590
 
1654
- const lastAct = this.lastActivityByAccount.get(acc) || 0;
1655
- if (updatedAt > lastAct) this.lastActivityByAccount.set(acc, updatedAt);
1591
+ this.lastDriftSnapshot = normalizeRegisterDriftSnapshot(data.lastDriftSnapshot);
1656
1592
 
1657
- const lastIn = this.lastInboundByAccount.get(acc) || 0;
1658
- if (updatedAt > lastIn) this.lastInboundByAccount.set(acc, updatedAt);
1659
- }
1660
- }
1593
+ // 兼容旧状态文件:若尚未持久化 lastSession*/lastActivity*,从 sessionRoutes 回填。
1594
+ this.backfillAccountActivityFromSessionRoutes();
1661
1595
  }
1662
1596
 
1663
1597
  private async flushState() {
1664
1598
  if (!this.statePath) return;
1665
1599
 
1666
- const sessionRoutes = Array.from(this.sessionRoutes.entries())
1667
- .map(([sessionKey, v]) => ({
1668
- sessionKey,
1669
- accountId: v.accountId,
1670
- route: v.route,
1671
- updatedAt: v.updatedAt,
1672
- }))
1673
- .slice(-MAX_SESSION_ROUTE_ENTRIES);
1674
-
1675
1600
  const data: PersistedState = {
1676
1601
  outbox: Array.from(this.outbox.values()),
1677
1602
  deadLetter: this.deadLetter.slice(-MAX_DEAD_LETTER_ENTRIES),
1678
- sessionRoutes,
1679
- lastSessionByAccount: Array.from(this.lastSessionByAccount.entries())
1680
- .map(([accountId, v]) => ({
1681
- accountId,
1682
- sessionKey: v.sessionKey,
1683
- scope: v.scope,
1684
- updatedAt: v.updatedAt,
1685
- }))
1686
- .slice(-MAX_ACCOUNT_ACTIVITY_ENTRIES),
1687
- lastActivityByAccount: Array.from(this.lastActivityByAccount.entries())
1688
- .map(([accountId, updatedAt]) => ({
1689
- accountId,
1690
- updatedAt,
1691
- }))
1692
- .slice(-MAX_ACCOUNT_ACTIVITY_ENTRIES),
1693
- lastInboundByAccount: Array.from(this.lastInboundByAccount.entries())
1694
- .map(([accountId, updatedAt]) => ({
1695
- accountId,
1696
- updatedAt,
1697
- }))
1698
- .slice(-MAX_ACCOUNT_ACTIVITY_ENTRIES),
1699
- lastOutboundByAccount: Array.from(this.lastOutboundByAccount.entries())
1700
- .map(([accountId, updatedAt]) => ({
1701
- accountId,
1702
- updatedAt,
1703
- }))
1704
- .slice(-MAX_ACCOUNT_ACTIVITY_ENTRIES),
1705
- lastDriftSnapshot: this.lastDriftSnapshot
1706
- ? {
1707
- capturedAt: this.lastDriftSnapshot.capturedAt,
1708
- registerCount: this.lastDriftSnapshot.registerCount,
1709
- apiGeneration: this.lastDriftSnapshot.apiGeneration,
1710
- postWarmupRegisterCount: this.lastDriftSnapshot.postWarmupRegisterCount,
1711
- apiInstanceId: this.lastDriftSnapshot.apiInstanceId,
1712
- registryFingerprint: this.lastDriftSnapshot.registryFingerprint,
1713
- dominantBucket: this.lastDriftSnapshot.dominantBucket,
1714
- sourceBuckets: { ...this.lastDriftSnapshot.sourceBuckets },
1715
- traceWindowSize: this.lastDriftSnapshot.traceWindowSize,
1716
- traceRecent: this.lastDriftSnapshot.traceRecent.map((trace) => ({ ...trace })),
1717
- }
1718
- : 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),
1719
1609
  };
1720
1610
 
1721
1611
  await writeOpenClawJsonFileAtomically(this.statePath, data);
@@ -1744,64 +1634,30 @@ class BncrBridgeRuntime {
1744
1634
  const primaryKey = this.activeConnectionByAccount.get(acc);
1745
1635
  const primary = primaryKey ? this.connections.get(primaryKey) : null;
1746
1636
 
1747
- const isEligible = (
1748
- conn: BncrConnection | null | undefined,
1749
- ): conn is BncrConnection & {
1750
- outboundReadyUntil?: number;
1751
- preferredForOutboundUntil?: number;
1752
- inboundOnly?: boolean;
1753
- } => {
1754
- if (!conn?.connId) return false;
1755
- if (t - conn.lastSeenAt > CONNECT_TTL_MS) return false;
1756
- if ((conn as any).inboundOnly === true) return false;
1757
- return true;
1758
- };
1759
-
1760
1637
  const recentInboundConnIds = this.resolveRecentInboundConnIds(acc);
1761
- const candidateScore = (conn: BncrConnection) => {
1762
- const preferredForOutboundUntil = finiteNumberOr((conn as any).preferredForOutboundUntil, 0);
1763
- const outboundReadyUntil = finiteNumberOr((conn as any).outboundReadyUntil, 0);
1764
- const lastPushTimeoutAt = finiteNumberOr((conn as any).lastPushTimeoutAt, 0);
1765
- const lastAckOkAt = finiteNumberOr((conn as any).lastAckOkAt, 0);
1766
- const pushFailureScore = finiteNumberOr((conn as any).pushFailureScore, 0);
1767
- const recentTimeoutPenalty = lastPushTimeoutAt > 0 && t - lastPushTimeoutAt <= 30_000 ? 1 : 0;
1768
- return {
1769
- preferred: preferredForOutboundUntil > t ? 1 : 0,
1770
- ready: outboundReadyUntil > t ? 1 : 0,
1771
- recentInbound: recentInboundConnIds.has(conn.connId) ? 1 : 0,
1772
- recentTimeoutPenalty,
1773
- pushFailureScore,
1774
- lastAckOkAt,
1775
- lastPushTimeoutAt,
1776
- lastSeenAt: conn.lastSeenAt,
1777
- connectedAt: conn.connectedAt,
1778
- };
1779
- };
1780
1638
 
1781
- if (isEligible(primary)) {
1782
- const score = candidateScore(primary);
1783
- 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;
1784
1652
  }
1785
1653
 
1786
- const candidates = Array.from(this.connections.values())
1787
- .filter((c): c is BncrConnection => c.accountId === acc)
1788
- .filter((c) => isEligible(c))
1789
- .sort((a, b) => {
1790
- const sa = candidateScore(a);
1791
- const sb = candidateScore(b);
1792
- if (sb.preferred !== sa.preferred) return sb.preferred - sa.preferred;
1793
- if (sb.ready !== sa.ready) return sb.ready - sa.ready;
1794
- if (sa.recentTimeoutPenalty !== sb.recentTimeoutPenalty)
1795
- return sa.recentTimeoutPenalty - sb.recentTimeoutPenalty;
1796
- if (sa.pushFailureScore !== sb.pushFailureScore)
1797
- return sa.pushFailureScore - sb.pushFailureScore;
1798
- if (sb.lastAckOkAt !== sa.lastAckOkAt) return sb.lastAckOkAt - sa.lastAckOkAt;
1799
- if (sa.lastPushTimeoutAt !== sb.lastPushTimeoutAt)
1800
- return sa.lastPushTimeoutAt - sb.lastPushTimeoutAt;
1801
- if (sb.recentInbound !== sa.recentInbound) return sb.recentInbound - sa.recentInbound;
1802
- if (sb.lastSeenAt !== sa.lastSeenAt) return sb.lastSeenAt - sa.lastSeenAt;
1803
- return sb.connectedAt - sa.connectedAt;
1804
- });
1654
+ const candidates = selectOrderedOutboundPushConnections({
1655
+ accountId: acc,
1656
+ now: t,
1657
+ connectTtlMs: CONNECT_TTL_MS,
1658
+ recentInboundConnIds,
1659
+ connections: this.connections.values(),
1660
+ });
1805
1661
 
1806
1662
  const next = candidates[0] || null;
1807
1663
  if (!next) return null;
@@ -1845,67 +1701,29 @@ class BncrBridgeRuntime {
1845
1701
  const t = now();
1846
1702
  const connIds = new Set<string>();
1847
1703
 
1848
- const isEligible = (
1849
- conn: BncrConnection | null | undefined,
1850
- ): conn is BncrConnection & {
1851
- outboundReadyUntil?: number;
1852
- preferredForOutboundUntil?: number;
1853
- inboundOnly?: boolean;
1854
- } => {
1855
- if (!conn?.connId) return false;
1856
- if (t - conn.lastSeenAt > CONNECT_TTL_MS) return false;
1857
- if ((conn as any).inboundOnly === true) return false;
1858
- return true;
1859
- };
1860
-
1861
1704
  const recentInboundConnIds = this.resolveRecentInboundConnIds(acc);
1862
- const candidateScore = (conn: BncrConnection) => {
1863
- const preferredForOutboundUntil = finiteNumberOr((conn as any).preferredForOutboundUntil, 0);
1864
- const outboundReadyUntil = finiteNumberOr((conn as any).outboundReadyUntil, 0);
1865
- const lastPushTimeoutAt = finiteNumberOr((conn as any).lastPushTimeoutAt, 0);
1866
- const lastAckOkAt = finiteNumberOr((conn as any).lastAckOkAt, 0);
1867
- const pushFailureScore = finiteNumberOr((conn as any).pushFailureScore, 0);
1868
- const recentTimeoutPenalty = lastPushTimeoutAt > 0 && t - lastPushTimeoutAt <= 30_000 ? 1 : 0;
1869
- return {
1870
- preferred: preferredForOutboundUntil > t ? 1 : 0,
1871
- ready: outboundReadyUntil > t ? 1 : 0,
1872
- recentInbound: recentInboundConnIds.has(conn.connId) ? 1 : 0,
1873
- recentTimeoutPenalty,
1874
- pushFailureScore,
1875
- lastAckOkAt,
1876
- lastPushTimeoutAt,
1877
- lastSeenAt: conn.lastSeenAt,
1878
- connectedAt: conn.connectedAt,
1879
- };
1880
- };
1881
1705
 
1882
1706
  const primaryKey = this.activeConnectionByAccount.get(acc);
1883
1707
  if (primaryKey) {
1884
1708
  const primary = this.connections.get(primaryKey);
1885
- if (isEligible(primary)) {
1709
+ if (
1710
+ isEligibleOutboundPushConnection({
1711
+ connection: primary,
1712
+ now: t,
1713
+ connectTtlMs: CONNECT_TTL_MS,
1714
+ })
1715
+ ) {
1886
1716
  connIds.add(primary.connId);
1887
1717
  }
1888
1718
  }
1889
1719
 
1890
- const candidates = Array.from(this.connections.values())
1891
- .filter((c): c is BncrConnection => c.accountId === acc)
1892
- .filter((c) => isEligible(c))
1893
- .sort((a, b) => {
1894
- const sa = candidateScore(a);
1895
- const sb = candidateScore(b);
1896
- if (sb.preferred !== sa.preferred) return sb.preferred - sa.preferred;
1897
- if (sb.ready !== sa.ready) return sb.ready - sa.ready;
1898
- if (sa.recentTimeoutPenalty !== sb.recentTimeoutPenalty)
1899
- return sa.recentTimeoutPenalty - sb.recentTimeoutPenalty;
1900
- if (sa.pushFailureScore !== sb.pushFailureScore)
1901
- return sa.pushFailureScore - sb.pushFailureScore;
1902
- if (sb.lastAckOkAt !== sa.lastAckOkAt) return sb.lastAckOkAt - sa.lastAckOkAt;
1903
- if (sa.lastPushTimeoutAt !== sb.lastPushTimeoutAt)
1904
- return sa.lastPushTimeoutAt - sb.lastPushTimeoutAt;
1905
- if (sb.recentInbound !== sa.recentInbound) return sb.recentInbound - sa.recentInbound;
1906
- if (sb.lastSeenAt !== sa.lastSeenAt) return sb.lastSeenAt - sa.lastSeenAt;
1907
- return sb.connectedAt - sa.connectedAt;
1908
- });
1720
+ const candidates = selectOrderedOutboundPushConnections({
1721
+ accountId: acc,
1722
+ now: t,
1723
+ connectTtlMs: CONNECT_TTL_MS,
1724
+ recentInboundConnIds,
1725
+ connections: this.connections.values(),
1726
+ });
1909
1727
 
1910
1728
  for (const c of candidates) {
1911
1729
  connIds.add(c.connId);
@@ -2202,6 +2020,7 @@ class BncrBridgeRuntime {
2202
2020
  audioAsVoice?: boolean;
2203
2021
  kind?: 'tool' | 'block' | 'final';
2204
2022
  replyToId?: string;
2023
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
2205
2024
  }): OutboxEntry {
2206
2025
  return buildFileTransferOutboxEntryFromRuntime({
2207
2026
  createMessageId: () => randomUUID(),
@@ -2218,6 +2037,7 @@ class BncrBridgeRuntime {
2218
2037
  audioAsVoice: params.audioAsVoice,
2219
2038
  kind: params.kind,
2220
2039
  replyToId: asString(params.replyToId || '').trim() || undefined,
2040
+ replyTargetPolicy: params.replyTargetPolicy,
2221
2041
  });
2222
2042
  }
2223
2043
 
@@ -2328,6 +2148,7 @@ class BncrBridgeRuntime {
2328
2148
  text: string;
2329
2149
  kind?: 'tool' | 'block' | 'final';
2330
2150
  replyToId?: string;
2151
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
2331
2152
  }): OutboxEntry {
2332
2153
  return buildTextOutboxEntryFromRuntime({
2333
2154
  createMessageId: () => randomUUID(),
@@ -2340,6 +2161,7 @@ class BncrBridgeRuntime {
2340
2161
  text: params.text,
2341
2162
  kind: params.kind,
2342
2163
  replyToId: params.replyToId,
2164
+ replyTargetPolicy: params.replyTargetPolicy,
2343
2165
  });
2344
2166
  }
2345
2167
 
@@ -3204,6 +3026,41 @@ class BncrBridgeRuntime {
3204
3026
  return Array.from(this.outbox.values()).filter((entry) => entry.accountId === acc);
3205
3027
  }
3206
3028
 
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
+
3207
3064
  private maybeLogOutboxDrainStuck(args: { accountId: string; trigger: string; reason: string }) {
3208
3065
  const acc = normalizeAccountId(args.accountId);
3209
3066
  const startedAt = this.pushDrainRunningSinceByAccount.get(acc) || 0;
@@ -3834,17 +3691,9 @@ class BncrBridgeRuntime {
3834
3691
  previousActiveConn,
3835
3692
  nextActiveKey: key,
3836
3693
  nextActiveConn: nextConn,
3837
- activeConnections: Array.from(this.connections.values())
3838
- .filter((c) => c.accountId === acc)
3839
- .map((c) => ({
3840
- connId: c.connId,
3841
- clientId: c.clientId,
3842
- connectedAt: c.connectedAt,
3843
- lastSeenAt: c.lastSeenAt,
3844
- outboundReadyUntil: (c as any).outboundReadyUntil || null,
3845
- preferredForOutboundUntil: (c as any).preferredForOutboundUntil || null,
3846
- inboundOnly: (c as any).inboundOnly === true,
3847
- })),
3694
+ activeConnections: this.buildActiveConnectionDebugList(acc, {
3695
+ includeOutboundState: true,
3696
+ }),
3848
3697
  })}`,
3849
3698
  { debugOnly: true },
3850
3699
  );
@@ -3864,17 +3713,9 @@ class BncrBridgeRuntime {
3864
3713
  previousActiveConn,
3865
3714
  nextActiveKey: key,
3866
3715
  nextActiveConn: nextConn,
3867
- activeConnections: Array.from(this.connections.values())
3868
- .filter((c) => c.accountId === acc)
3869
- .map((c) => ({
3870
- connId: c.connId,
3871
- clientId: c.clientId,
3872
- connectedAt: c.connectedAt,
3873
- lastSeenAt: c.lastSeenAt,
3874
- outboundReadyUntil: (c as any).outboundReadyUntil || null,
3875
- preferredForOutboundUntil: (c as any).preferredForOutboundUntil || null,
3876
- inboundOnly: (c as any).inboundOnly === true,
3877
- })),
3716
+ activeConnections: this.buildActiveConnectionDebugList(acc, {
3717
+ includeOutboundState: true,
3718
+ }),
3878
3719
  })}`,
3879
3720
  { debugOnly: true },
3880
3721
  );
@@ -4365,45 +4206,6 @@ class BncrBridgeRuntime {
4365
4206
  return true;
4366
4207
  }
4367
4208
 
4368
- private computeRecommendedAckTimeoutReason(args: {
4369
- lateAckOkCount: number;
4370
- recentAckTimeoutCount: number;
4371
- lastLateAckPushLatencyMs: number | null;
4372
- lastLateAckOkAt?: number | null;
4373
- adaptiveAckRecoveryOkCount?: number;
4374
- recommendedAckTimeoutMs?: number;
4375
- nowMs?: number;
4376
- }) {
4377
- return computeBncrRecommendedAckTimeoutReason({
4378
- ...args,
4379
- nowMs: typeof args.nowMs === 'number' ? args.nowMs : now(),
4380
- defaultAckTimeoutMs: PUSH_ACK_TIMEOUT_MS,
4381
- minAckTimeoutMs: RECOMMENDED_ACK_TIMEOUT_MIN_MS,
4382
- maxAckTimeoutMs: RECOMMENDED_ACK_TIMEOUT_MAX_MS,
4383
- lateAckObservationTtlMs: ADAPTIVE_ACK_TIMEOUT_OBSERVATION_TTL_MS,
4384
- recoveryOkThreshold: ADAPTIVE_ACK_TIMEOUT_RECOVERY_OK_THRESHOLD,
4385
- });
4386
- }
4387
-
4388
- private computeRecommendedAckTimeoutMs(args: {
4389
- lateAckOkCount: number;
4390
- recentAckTimeoutCount: number;
4391
- lastLateAckPushLatencyMs: number | null;
4392
- lastLateAckOkAt?: number | null;
4393
- adaptiveAckRecoveryOkCount?: number;
4394
- nowMs?: number;
4395
- }) {
4396
- return computeBncrRecommendedAckTimeoutMs({
4397
- ...args,
4398
- nowMs: typeof args.nowMs === 'number' ? args.nowMs : now(),
4399
- defaultAckTimeoutMs: PUSH_ACK_TIMEOUT_MS,
4400
- minAckTimeoutMs: RECOMMENDED_ACK_TIMEOUT_MIN_MS,
4401
- maxAckTimeoutMs: RECOMMENDED_ACK_TIMEOUT_MAX_MS,
4402
- lateAckObservationTtlMs: ADAPTIVE_ACK_TIMEOUT_OBSERVATION_TTL_MS,
4403
- recoveryOkThreshold: ADAPTIVE_ACK_TIMEOUT_RECOVERY_OK_THRESHOLD,
4404
- });
4405
- }
4406
-
4407
4209
  private maybeLogAdaptiveAckTimeout(args: {
4408
4210
  accountId: string;
4409
4211
  timeoutMs: number;
@@ -4451,22 +4253,18 @@ class BncrBridgeRuntime {
4451
4253
  acc,
4452
4254
  );
4453
4255
  const nowMs = now();
4454
- const timeoutMs = this.computeRecommendedAckTimeoutMs({
4256
+ const { timeoutMs, reason } = resolveBncrRuntimeAckTimeoutDecision({
4455
4257
  lateAckOkCount,
4456
4258
  recentAckTimeoutCount,
4457
4259
  lastLateAckPushLatencyMs,
4458
4260
  lastLateAckOkAt,
4459
4261
  adaptiveAckRecoveryOkCount,
4460
4262
  nowMs,
4461
- });
4462
- const reason = this.computeRecommendedAckTimeoutReason({
4463
- lateAckOkCount,
4464
- recentAckTimeoutCount,
4465
- lastLateAckPushLatencyMs,
4466
- lastLateAckOkAt,
4467
- adaptiveAckRecoveryOkCount,
4468
- recommendedAckTimeoutMs: timeoutMs,
4469
- 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,
4470
4268
  });
4471
4269
  this.maybeLogAdaptiveAckTimeout({
4472
4270
  accountId: acc,
@@ -4485,58 +4283,30 @@ class BncrBridgeRuntime {
4485
4283
  const lastLateAckPushLatencyMs = this.lastLateAckPushLatencyMsByAccount.get(acc) || null;
4486
4284
  const lastLateAckOkAt = this.lastLateAckOkByAccount.get(acc) || null;
4487
4285
  const nowMs = now();
4488
- const lastLateAckAgeMs =
4489
- typeof lastLateAckOkAt === 'number' && lastLateAckOkAt > 0
4490
- ? Math.max(0, nowMs - lastLateAckOkAt)
4491
- : null;
4492
- const lateAckObservationTtlMs = ADAPTIVE_ACK_TIMEOUT_OBSERVATION_TTL_MS;
4493
- const lateAckObservationExpired =
4494
- typeof lastLateAckAgeMs === 'number' && lastLateAckAgeMs > lateAckObservationTtlMs;
4495
4286
  const adaptiveAckRecoveryOkCount = this.getCounter(
4496
4287
  this.adaptiveAckRecoveryOkCountByAccount,
4497
4288
  acc,
4498
4289
  );
4499
- const adaptiveAckRecovered =
4500
- adaptiveAckRecoveryOkCount >= ADAPTIVE_ACK_TIMEOUT_RECOVERY_OK_THRESHOLD;
4501
- const recommendedAckTimeoutMs = this.computeRecommendedAckTimeoutMs({
4502
- lateAckOkCount,
4503
- recentAckTimeoutCount,
4504
- lastLateAckPushLatencyMs,
4505
- lastLateAckOkAt,
4506
- adaptiveAckRecoveryOkCount,
4507
- nowMs,
4508
- });
4509
- const currentAckTimeoutMs = this.resolveMessageAckTimeoutMs(acc);
4510
- return {
4290
+ return buildBncrRuntimeAckObservability({
4511
4291
  lastAckOkAt: this.lastAckOkByAccount.get(acc) || null,
4512
4292
  lastAckTimeoutAt: this.lastAckTimeoutByAccount.get(acc) || null,
4513
4293
  recentAckTimeoutCount,
4514
4294
  lateAckOkCount,
4515
4295
  lastLateAckOkAt,
4516
- lastLateAckAgeMs,
4517
- lateAckObservationTtlMs,
4518
- lateAckObservationExpired,
4519
4296
  adaptiveAckRecoveryOkCount,
4520
- adaptiveAckRecoveryOkThreshold: ADAPTIVE_ACK_TIMEOUT_RECOVERY_OK_THRESHOLD,
4521
- adaptiveAckRecovered,
4522
4297
  lastAckQueueLatencyMs: this.lastAckQueueLatencyMsByAccount.get(acc) || null,
4523
4298
  lastAckPushLatencyMs: this.lastAckPushLatencyMsByAccount.get(acc) || null,
4524
4299
  lastLateAckQueueLatencyMs: this.lastLateAckQueueLatencyMsByAccount.get(acc) || null,
4525
4300
  lastLateAckPushLatencyMs,
4526
4301
  adaptiveAckTimeoutEnabled: ADAPTIVE_ACK_TIMEOUT_DEFAULT_ENABLED,
4527
4302
  defaultAckTimeoutMs: PUSH_ACK_TIMEOUT_MS,
4528
- currentAckTimeoutMs,
4529
- recommendedAckTimeoutMs,
4530
- recommendedAckTimeoutReason: this.computeRecommendedAckTimeoutReason({
4531
- lateAckOkCount,
4532
- recentAckTimeoutCount,
4533
- lastLateAckPushLatencyMs,
4534
- lastLateAckOkAt,
4535
- adaptiveAckRecoveryOkCount,
4536
- recommendedAckTimeoutMs,
4537
- nowMs,
4538
- }),
4539
- };
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
+ });
4540
4310
  }
4541
4311
 
4542
4312
  private buildRuntimeAckStrategy(ackObservability: Record<string, any>) {
@@ -4681,6 +4451,8 @@ class BncrBridgeRuntime {
4681
4451
  entry: dead,
4682
4452
  maxEntries: MAX_DEAD_LETTER_ENTRIES,
4683
4453
  });
4454
+ this.incrementCounter(this.deadLetterSinceStartByAccount, dead.accountId);
4455
+ this.logDeadLetterSummary(dead.accountId, { source: 'move' });
4684
4456
  this.outbox.delete(entry.messageId);
4685
4457
  this.resolveMessageAck(entry.messageId, 'timeout');
4686
4458
  this.scheduleSave();
@@ -4740,14 +4512,7 @@ class BncrBridgeRuntime {
4740
4512
  ? this.resolveRecentInboundConnIds(args.accountId)
4741
4513
  : new Set<string>();
4742
4514
  const activeConnectionKey = this.activeConnectionByAccount.get(args.accountId) || null;
4743
- const accountConnections = Array.from(this.connections.values())
4744
- .filter((c) => c.accountId === args.accountId)
4745
- .map((c) => ({
4746
- connId: c.connId,
4747
- clientId: c.clientId,
4748
- connectedAt: c.connectedAt,
4749
- lastSeenAt: c.lastSeenAt,
4750
- }));
4515
+ const accountConnections = this.buildActiveConnectionDebugList(args.accountId);
4751
4516
 
4752
4517
  return {
4753
4518
  directConnIds,
@@ -5262,9 +5027,10 @@ class BncrBridgeRuntime {
5262
5027
  route: BncrRoute;
5263
5028
  payload: ReplyPayloadInput;
5264
5029
  mediaLocalRoots?: readonly string[];
5030
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
5265
5031
  }) {
5266
- const { accountId, sessionKey, route, payload, mediaLocalRoots } = params;
5267
- const normalized = normalizeReplyPayload(payload, { asString });
5032
+ const { accountId, sessionKey, route, payload, mediaLocalRoots, replyTargetPolicy } = params;
5033
+ const normalized = normalizeReplyPayload(payload, { asString }, { replyTargetPolicy });
5268
5034
 
5269
5035
  enqueueNormalizedReplyPayload(
5270
5036
  {
@@ -5400,9 +5166,7 @@ class BncrBridgeRuntime {
5400
5166
  pushEvent: BNCR_PUSH_EVENT,
5401
5167
  online: true,
5402
5168
  isPrimary: this.isPrimaryConnection(accountId, clientId),
5403
- activeConnections: this.activeConnectionCount(accountId),
5404
- pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
5405
- deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
5169
+ ...this.buildAccountQueueCounters(accountId),
5406
5170
  diagnostics: this.buildExtendedDiagnostics(accountId),
5407
5171
  runtimeFlags: this.buildRuntimeFlags(accountId),
5408
5172
  waiters: {
@@ -5496,9 +5260,7 @@ class BncrBridgeRuntime {
5496
5260
  accountId,
5497
5261
  ok: true,
5498
5262
  event: 'activity',
5499
- activeConnections: this.activeConnectionCount(accountId),
5500
- pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
5501
- deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
5263
+ ...this.buildAccountQueueCounters(accountId),
5502
5264
  now: now(),
5503
5265
  });
5504
5266
  this.flushPushQueueBestEffort({
@@ -5536,6 +5298,78 @@ class BncrBridgeRuntime {
5536
5298
  );
5537
5299
  };
5538
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
+
5539
5373
  handleFileInit = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
5540
5374
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
5541
5375
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
@@ -5977,7 +5811,22 @@ class BncrBridgeRuntime {
5977
5811
  return;
5978
5812
  }
5979
5813
 
5814
+ if (!['init', 'chunk', 'complete', 'abort'].includes(stage)) {
5815
+ respond(false, { error: 'invalid file ack stage' });
5816
+ return;
5817
+ }
5818
+
5980
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
+
5981
5830
  const staleKind =
5982
5831
  stage === 'init'
5983
5832
  ? 'file.init'
@@ -6157,14 +6006,7 @@ class BncrBridgeRuntime {
6157
6006
  onlineAfterSeen: this.isOnline(accountId),
6158
6007
  recentInboundReachable: this.hasRecentInboundReachability(accountId),
6159
6008
  activeConnectionKey: this.activeConnectionByAccount.get(accountId) || null,
6160
- activeConnections: Array.from(this.connections.values())
6161
- .filter((c) => c.accountId === accountId)
6162
- .map((c) => ({
6163
- connId: c.connId,
6164
- clientId: c.clientId,
6165
- connectedAt: c.connectedAt,
6166
- lastSeenAt: c.lastSeenAt,
6167
- })),
6009
+ activeConnections: this.buildActiveConnectionDebugList(accountId),
6168
6010
  }),
6169
6011
  )}`,
6170
6012
  { debugOnly: true },
@@ -6272,7 +6114,7 @@ class BncrBridgeRuntime {
6272
6114
  }) {
6273
6115
  this.logInfo(
6274
6116
  'outbound',
6275
- `send-entry:${args.kind} ${JSON.stringify({
6117
+ buildBncrDebugJsonMessage(`send-entry:${args.kind}`, {
6276
6118
  accountId: args.accountId,
6277
6119
  to: args.to,
6278
6120
  text: args.payload.text,
@@ -6288,7 +6130,7 @@ class BncrBridgeRuntime {
6288
6130
  threadId: args.ctx?.threadId,
6289
6131
  replyToId: args.ctx?.replyToId,
6290
6132
  },
6291
- })}`,
6133
+ }),
6292
6134
  { debugOnly: true },
6293
6135
  );
6294
6136
  }