@xmoxmo/bncr 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/channel.ts CHANGED
@@ -12,7 +12,6 @@ import {
12
12
  listAccountIds,
13
13
  normalizeAccountId,
14
14
  resolveAccount,
15
- resolveDefaultDisplayName,
16
15
  } from './core/accounts.ts';
17
16
  import { BncrConfigSchema } from './core/config-schema.ts';
18
17
  import {
@@ -21,18 +20,33 @@ import {
21
20
  clearOutboundCapability,
22
21
  findCapabilityConnection,
23
22
  } from './core/connection-capability.ts';
23
+ import {
24
+ getRevalidatedAttemptReason,
25
+ hasAlternativeLiveConnection as hasAlternativeLiveConnectionFromRuntime,
26
+ hasRecentInboundReachability as hasRecentInboundReachabilityFromRuntime,
27
+ isRecentlyReachableConn as isRecentlyReachableConnFromRuntime,
28
+ resolveRecentInboundConnIds as resolveRecentInboundConnIdsFromRuntime,
29
+ } from './core/connection-reachability.ts';
30
+ import { buildDiagnosticsPayload } from './core/diagnostics.ts';
31
+ import { buildDownlinkHealth as buildDownlinkHealthFromRuntime } from './core/downlink-health.ts';
32
+ import { buildExtendedDiagnostics as buildExtendedDiagnosticsFromRuntime } from './core/extended-diagnostics.ts';
33
+ import { buildFileAckKey } from './core/file-ack.ts';
34
+ import {
35
+ buildFileTransferAbortPayload,
36
+ buildFileTransferChunkPayload,
37
+ buildFileTransferCompletePayload,
38
+ buildFileTransferInitPayload,
39
+ } from './core/file-transfer-payloads.ts';
40
+ import {
41
+ matchesTransferOwner as matchesTransferOwnerFromRuntime,
42
+ observeLeaseState,
43
+ } from './core/lease-state.ts';
44
+ import { emitBncrLog, emitBncrLogLine } from './core/logging.ts';
45
+ import { buildOutboxEnqueueDebugInfo } from './core/outbox-enqueue.ts';
24
46
  import {
25
47
  buildFileTransferOutboxEntry as buildFileTransferOutboxEntryFromRuntime,
26
48
  buildTextOutboxEntry as buildTextOutboxEntryFromRuntime,
27
49
  } from './core/outbox-entry-builders.ts';
28
- import { buildOutboxEnqueueDebugInfo } from './core/outbox-enqueue.ts';
29
- import {
30
- appendDeadLetter,
31
- buildDeadLetterEntry,
32
- collectDueOutboxEntries,
33
- } from './core/outbox-queue.ts';
34
- import { resolveFileTransferGuard } from './core/outbox-file-transfer-guards.ts';
35
- import { prepareFileTransferRouteSelection } from './core/outbox-file-transfer-prep.ts';
36
50
  import {
37
51
  buildFileTransferPushOkArgs,
38
52
  buildFileTransferPushSuccessArgs,
@@ -41,61 +55,36 @@ import {
41
55
  buildFileTransferPushFailureArgs,
42
56
  resolveFileTransferFailureState,
43
57
  } from './core/outbox-file-transfer-failure.ts';
58
+ import { resolveFileTransferGuard } from './core/outbox-file-transfer-guards.ts';
59
+ import { prepareFileTransferRouteSelection } from './core/outbox-file-transfer-prep.ts';
44
60
  import {
45
61
  buildFileTransferBroadcastPayload,
46
62
  buildFileTransferRouteSelectArgs,
47
63
  } from './core/outbox-file-transfer-success.ts';
64
+ import {
65
+ appendDeadLetter,
66
+ buildDeadLetterEntry,
67
+ collectDueOutboxEntries,
68
+ } from './core/outbox-queue.ts';
48
69
  import { summarizeOutboxEntry } from './core/outbox-summary.ts';
70
+ import { buildTextPushFailureArgs } from './core/outbox-text-push-failure.ts';
49
71
  import { resolveTextPushGuard } from './core/outbox-text-push-guards.ts';
50
72
  import { prepareTextPushRouteSelection } from './core/outbox-text-push-prep.ts';
51
- import { buildTextPushFailureArgs } from './core/outbox-text-push-failure.ts';
52
73
  import {
53
74
  buildTextPushBroadcastPayload,
54
75
  buildTextPushOkArgs,
55
76
  buildTextPushRouteSelectArgs,
56
77
  buildTextPushSuccessArgs,
57
78
  } from './core/outbox-text-push-success.ts';
58
- import {
59
- getRevalidatedAttemptReason,
60
- hasAlternativeLiveConnection as hasAlternativeLiveConnectionFromRuntime,
61
- hasRecentInboundReachability as hasRecentInboundReachabilityFromRuntime,
62
- isRecentlyReachableConn as isRecentlyReachableConnFromRuntime,
63
- resolveRecentInboundConnIds as resolveRecentInboundConnIdsFromRuntime,
64
- } from './core/connection-reachability.ts';
65
- import { buildDiagnosticsPayload } from './core/diagnostics.ts';
66
- import { buildDownlinkHealth as buildDownlinkHealthFromRuntime } from './core/downlink-health.ts';
67
- import { buildExtendedDiagnostics as buildExtendedDiagnosticsFromRuntime } from './core/extended-diagnostics.ts';
68
- import { observeLeaseState, matchesTransferOwner as matchesTransferOwnerFromRuntime } from './core/lease-state.ts';
69
- import { emitBncrLog, emitBncrLogLine } from './core/logging.ts';
70
79
  import { resolveBncrChannelPolicy, resolveBncrConfigWarnings } from './core/policy.ts';
71
80
  import {
72
- getOpenClawRuntimeConfig,
73
- getOpenClawRuntimeConfigOrDefault,
74
- } from './openclaw/config-runtime.ts';
75
- import {
76
- loadOpenClawWebMedia,
77
- saveOpenClawChannelMediaBuffer,
78
- type OpenClawLoadedMedia,
79
- } from './openclaw/media-runtime.ts';
80
- import { resolveOpenClawAgentRoute } from './openclaw/routing-runtime.ts';
81
- import {
82
- applyOpenClawAccountNameToChannelSection,
83
- createOpenClawDefaultChannelRuntimeState,
84
- extractOpenClawToolSend,
85
- openClawJsonResult,
86
- readOpenClawBooleanParam,
87
- readOpenClawJsonFileWithFallback,
88
- readOpenClawStringParam,
89
- setOpenClawAccountEnabledInConfigSection,
90
- writeOpenClawJsonFileAtomically,
91
- } from './openclaw/sdk-helpers.ts';
92
- import {
81
+ appendBoundedRegisterTrace,
82
+ buildRegisterDriftSnapshot,
83
+ buildRegisterTraceEntry,
93
84
  buildRegisterTraceSummary as buildRegisterTraceSummaryFromEntries,
94
- classifyRegisterTrace as classifyRegisterTraceFromStack,
95
85
  } from './core/register-trace.ts';
96
86
  import {
97
87
  buildAccountRuntimeSnapshot,
98
- buildAccountStatusSnapshot,
99
88
  buildIntegratedDiagnostics as buildIntegratedDiagnosticsFromRuntime,
100
89
  buildStatusHeadlineFromRuntime,
101
90
  buildStatusMetaFromRuntime,
@@ -103,11 +92,9 @@ import {
103
92
  import {
104
93
  buildCanonicalBncrSessionKey,
105
94
  formatDisplayScope,
106
- formatTargetDisplay,
107
95
  isLowerHex,
108
96
  normalizeInboundSessionKey,
109
97
  normalizeStoredSessionKey,
110
- parseExplicitTarget,
111
98
  parseRouteFromDisplayScope,
112
99
  parseRouteFromHexScope,
113
100
  parseRouteFromScope,
@@ -127,14 +114,12 @@ import {
127
114
  reactBncrMessageAction,
128
115
  sendBncrReplyAction,
129
116
  } from './messaging/outbound/actions.ts';
130
- import {
131
- buildBncrMediaOutboundFrame,
132
- resolveBncrOutboundMessageType,
133
- } from './messaging/outbound/media.ts';
134
117
  import {
135
118
  buildEnqueueFromReplyDebugInfo,
136
119
  buildFlushDebugInfo,
137
120
  buildOutboxAckDebugInfo,
121
+ buildOutboxDrainSkipDebugInfo,
122
+ buildOutboxDrainStuckDebugInfo,
138
123
  buildOutboxPushOkDebugInfo,
139
124
  buildOutboxPushSkipDebugInfo,
140
125
  buildOutboxRouteSelectDebugInfo,
@@ -143,6 +128,28 @@ import {
143
128
  buildReplyMediaFallbackDebugInfo,
144
129
  buildRetryRerouteDebugInfo,
145
130
  } from './messaging/outbound/diagnostics.ts';
131
+ import {
132
+ buildBncrMediaOutboundFrame,
133
+ resolveBncrOutboundMessageType,
134
+ } from './messaging/outbound/media.ts';
135
+ import {
136
+ getOpenClawRuntimeConfig,
137
+ getOpenClawRuntimeConfigOrDefault,
138
+ } from './openclaw/config-runtime.ts';
139
+ import {
140
+ type OpenClawLoadedMedia,
141
+ loadOpenClawWebMedia,
142
+ saveOpenClawChannelMediaBuffer,
143
+ } from './openclaw/media-runtime.ts';
144
+ import { resolveOpenClawAgentRoute } from './openclaw/routing-runtime.ts';
145
+ import {
146
+ extractOpenClawToolSend,
147
+ openClawJsonResult,
148
+ readOpenClawBooleanParam,
149
+ readOpenClawJsonFileWithFallback,
150
+ readOpenClawStringParam,
151
+ writeOpenClawJsonFileAtomically,
152
+ } from './openclaw/sdk-helpers.ts';
146
153
 
147
154
  function buildInboundAcceptedLifecycleDebugInfo(args: {
148
155
  stage: 'accepted';
@@ -276,33 +283,13 @@ function buildInboundResponsePayload(
276
283
  };
277
284
  }
278
285
  }
286
+ import { buildBncrDurableQueuedResult } from './messaging/outbound/durable-queue-adapter.ts';
279
287
  import {
280
- buildMediaTextFallback,
281
288
  type MediaDedupeCacheEntry,
289
+ buildMediaTextFallback,
282
290
  normalizeMessageText,
283
291
  normalizeReplyToId,
284
292
  } from './messaging/outbound/media-dedupe.ts';
285
- import {
286
- buildReplyTextOutboxEntry,
287
- enqueueNormalizedReplyPayload,
288
- enqueueReplyMediaFallbackTextEntry,
289
- enqueueReplyMediaFileTransferEntry,
290
- enqueueSingleReplyMediaEntry,
291
- enqueueReplyTextEntry,
292
- hasReplyMediaEntries,
293
- normalizeReplyPayload,
294
- type NormalizedReplyPayload,
295
- type ReplyMediaEntriesParams,
296
- type ReplyMediaFileTransferParams,
297
- type ReplyPayloadInput,
298
- } from './messaging/outbound/reply-enqueue.ts';
299
- import {
300
- OUTBOUND_DEGRADE_REASON,
301
- OUTBOUND_FLUSH_REASON,
302
- OUTBOUND_FLUSH_TRIGGER,
303
- OUTBOUND_SCHEDULE_SOURCE,
304
- OUTBOUND_TERMINAL_REASON,
305
- } from './messaging/outbound/reasons.ts';
306
293
  import {
307
294
  buildOutboxOnlineDebugInfo,
308
295
  clampOutboxDrainDelay,
@@ -315,17 +302,73 @@ import {
315
302
  selectOutboxTargetAccounts,
316
303
  updateMinOutboxDelay,
317
304
  } from './messaging/outbound/queue-selectors.ts';
305
+ import {
306
+ OUTBOUND_DEGRADE_REASON,
307
+ OUTBOUND_FLUSH_REASON,
308
+ OUTBOUND_FLUSH_TRIGGER,
309
+ OUTBOUND_SCHEDULE_SOURCE,
310
+ OUTBOUND_TERMINAL_REASON,
311
+ } from './messaging/outbound/reasons.ts';
312
+ import {
313
+ type NormalizedReplyPayload,
314
+ type ReplyMediaEntriesParams,
315
+ type ReplyMediaFileTransferParams,
316
+ type ReplyPayloadInput,
317
+ buildReplyTextOutboxEntry,
318
+ enqueueNormalizedReplyPayload,
319
+ enqueueReplyMediaFallbackTextEntry,
320
+ enqueueReplyMediaFileTransferEntry,
321
+ enqueueReplyTextEntry,
322
+ enqueueSingleReplyMediaEntry,
323
+ hasReplyMediaEntries,
324
+ normalizeReplyPayload,
325
+ } from './messaging/outbound/reply-enqueue.ts';
318
326
  import {
319
327
  computePushFailureDecision,
320
328
  computeRetryRerouteDecision,
321
329
  } from './messaging/outbound/retry-policy.ts';
322
330
  import { sendBncrMedia, sendBncrText } from './messaging/outbound/send.ts';
323
- import { buildBncrDurableQueuedResult } from './messaging/outbound/durable-queue-adapter.ts';
324
- import { resolveBncrOutboundSessionRoute } from './messaging/outbound/session-route.ts';
331
+ import { BNCR_CHANNEL_CAPABILITIES } from './plugin/capabilities.ts';
332
+ import { BNCR_CONFIG_SURFACE } from './plugin/config.ts';
333
+ import { BNCR_GATEWAY_METHODS } from './plugin/gateway-methods.ts';
334
+ import { createBncrGatewayRuntime } from './plugin/gateway-runtime.ts';
335
+ import { BNCR_MESSAGE_RECEIVE_POLICY } from './plugin/message-policy.ts';
336
+ import { createBncrMessageSend } from './plugin/message-send.ts';
337
+ import { createBncrMessagingSurface } from './plugin/messaging.ts';
338
+ import { BNCR_CHANNEL_META } from './plugin/meta.ts';
339
+ import { createBncrOutboundRuntime } from './plugin/outbound.ts';
340
+ import { BNCR_SETUP_SURFACE } from './plugin/setup.ts';
341
+ import { createBncrStatusSurface } from './plugin/status.ts';
342
+ import {
343
+ pruneLogDedupeState as pruneLogDedupeStateFromRuntime,
344
+ shouldEmitDedupLog as shouldEmitDedupLogFromRuntime,
345
+ } from './runtime/log-dedupe.ts';
346
+ import {
347
+ buildBncrRuntimeAckStrategy,
348
+ computeBncrRecommendedAckTimeoutMs,
349
+ computeBncrRecommendedAckTimeoutReason,
350
+ } from './runtime/outbound-ack-timeout.ts';
351
+ import {
352
+ buildBncrRuntimeFlags,
353
+ buildBncrRuntimeStatusInput,
354
+ resolveBncrOutboundAckRequired,
355
+ } from './runtime/outbound-flags.ts';
325
356
  import {
326
- looksLikeBncrExplicitTarget,
327
- resolveBncrOutboundTarget,
328
- } from './messaging/outbound/target-resolver.ts';
357
+ applyBncrPushFailureDecisionToEntry,
358
+ applyBncrRetryRerouteDecisionToEntry,
359
+ buildBncrAckOkTelemetryPatch,
360
+ buildBncrAckRetryEntryPatch,
361
+ buildBncrOutboxFailureEntryPatch,
362
+ buildBncrOutboxPushSuccessEntryPatch,
363
+ } from './runtime/outbox-transitions.ts';
364
+ import { buildRuntimeStatusSnapshots } from './runtime/status-snapshots.ts';
365
+ import {
366
+ type ChannelAccountWorkerHandle,
367
+ clearAllBncrStatusWorkers,
368
+ clearBncrStatusWorker,
369
+ startBncrStatusWorker,
370
+ stopBncrStatusWorker,
371
+ } from './runtime/status-worker.ts';
329
372
  const BRIDGE_VERSION = 2;
330
373
  const BNCR_PUSH_EVENT = 'plugin.bncr.push';
331
374
  const BNCR_FILE_INIT_EVENT = 'plugin.bncr.file.init';
@@ -341,6 +384,9 @@ const MAX_ACCOUNT_ACTIVITY_ENTRIES = 1000;
341
384
  const PUSH_DRAIN_INTERVAL_MS = 500;
342
385
  const PUSH_DRAIN_ACCOUNT_BUDGET = 5;
343
386
  const PUSH_DRAIN_ACCOUNT_TIME_BUDGET_MS = 2_000;
387
+ const PUSH_DRAIN_EXCEPTION_RETRY_LIMIT = 3;
388
+ const PUSH_DRAIN_EXCEPTION_RETRY_DELAY_MS = 1_000;
389
+ const PUSH_DRAIN_STUCK_WARN_MS = 30_000;
344
390
  const PUSH_ACK_TIMEOUT_MS = 30_000;
345
391
  const ADAPTIVE_ACK_TIMEOUT_DEFAULT_ENABLED = true;
346
392
  const RECOMMENDED_ACK_TIMEOUT_MIN_MS = PUSH_ACK_TIMEOUT_MS;
@@ -351,12 +397,11 @@ const ADAPTIVE_ACK_TIMEOUT_LOG_THROTTLE_MS = 5 * 60 * 1000;
351
397
  const OUTBOUND_READY_TTL_MS = 30_000;
352
398
  const PREFERRED_OUTBOUND_TTL_MS = 12_000;
353
399
  const FILE_FORCE_CHUNK = true; // 统一走 WS 分块,保留 base64 仅作兜底
354
- const LOG_DEDUPE_STATE_TTL_MS = 10 * 60 * 1000;
355
- const LOG_DEDUPE_STATE_MAX_ENTRIES = 1_000;
356
400
  const FILE_INLINE_THRESHOLD = 5 * 1024 * 1024; // fallback 阈值(仅 FILE_FORCE_CHUNK=false 时生效)
357
401
  const FILE_CHUNK_SIZE = 256 * 1024; // 256KB
358
402
  const INBOUND_FILE_TRANSFER_MAX_BYTES = 50 * 1024 * 1024;
359
- const INBOUND_FILE_TRANSFER_MAX_CHUNKS = Math.ceil(INBOUND_FILE_TRANSFER_MAX_BYTES / FILE_CHUNK_SIZE) + 1;
403
+ const INBOUND_FILE_TRANSFER_MAX_CHUNKS =
404
+ Math.ceil(INBOUND_FILE_TRANSFER_MAX_BYTES / FILE_CHUNK_SIZE) + 1;
360
405
  const FILE_CHUNK_RETRY = 3;
361
406
  const FILE_ACK_TIMEOUT_MS = 30_000;
362
407
  const FILE_TRANSFER_ACK_TTL_MS = 30_000;
@@ -389,12 +434,6 @@ type FileSendTransferState = {
389
434
  error?: string;
390
435
  };
391
436
 
392
- type ChannelAccountWorkerHandle = {
393
- timer: NodeJS.Timeout;
394
- finish: (reason: string) => void;
395
- cleanupAbortListener?: () => void;
396
- };
397
-
398
437
  type FileRecvTransferState = {
399
438
  transferId: string;
400
439
  accountId: string;
@@ -528,7 +567,6 @@ function normalizeBncrSendParams(input: {
528
567
  };
529
568
  }
530
569
 
531
-
532
570
  function now() {
533
571
  return Date.now();
534
572
  }
@@ -725,7 +763,10 @@ class BncrBridgeRuntime {
725
763
  private lastLateAckQueueLatencyMsByAccount = new Map<string, number>();
726
764
  private lastLateAckPushLatencyMsByAccount = new Map<string, number>();
727
765
  private adaptiveAckRecoveryOkCountByAccount = new Map<string, number>();
728
- private adaptiveAckTimeoutLogStateByAccount = new Map<string, { at: number; timeoutMs: number; reason: string }>();
766
+ private adaptiveAckTimeoutLogStateByAccount = new Map<
767
+ string,
768
+ { at: number; timeoutMs: number; reason: string }
769
+ >();
729
770
  private channelAccountWorkers = new Map<string, ChannelAccountWorkerHandle>();
730
771
  private logDedupeState = new Map<string, { at: number; sig: string }>();
731
772
  private canonicalAgentId: string | null = null;
@@ -743,6 +784,9 @@ class BncrBridgeRuntime {
743
784
  private saveTimer: NodeJS.Timeout | null = null;
744
785
  private pushTimer: NodeJS.Timeout | null = null;
745
786
  private pushDrainRunningAccounts = new Set<string>();
787
+ private pushDrainRunningSinceByAccount = new Map<string, number>();
788
+ private pushDrainStuckWarnedAtByAccount = new Map<string, number>();
789
+ private pushDrainExceptionRetryCount = 0;
746
790
  private messageAckWaiters = new Map<
747
791
  // Refactor boundary note (message ACK runtime):
748
792
  // These waiters are part of the outbound message-ack lifecycle, not just a utility map.
@@ -837,27 +881,17 @@ class BncrBridgeRuntime {
837
881
  }
838
882
 
839
883
  private pruneLogDedupeState(currentTime = now()) {
840
- for (const [key, entry] of this.logDedupeState.entries()) {
841
- if (currentTime - entry.at > LOG_DEDUPE_STATE_TTL_MS) {
842
- this.logDedupeState.delete(key);
843
- }
844
- }
845
-
846
- while (this.logDedupeState.size > LOG_DEDUPE_STATE_MAX_ENTRIES) {
847
- const oldestKey = this.logDedupeState.keys().next().value;
848
- if (!oldestKey) break;
849
- this.logDedupeState.delete(oldestKey);
850
- }
884
+ pruneLogDedupeStateFromRuntime(this.logDedupeState, currentTime);
851
885
  }
852
886
 
853
887
  private shouldEmitDedupLog(key: string, sig: string, windowMs = 5 * 60 * 1000) {
854
- const t = now();
855
- this.pruneLogDedupeState(t);
856
- const prev = this.logDedupeState.get(key) || null;
857
- if (prev && prev.sig === sig && t - prev.at < windowMs) return false;
858
- this.logDedupeState.set(key, { at: t, sig });
859
- this.pruneLogDedupeState(t);
860
- return true;
888
+ return shouldEmitDedupLogFromRuntime({
889
+ state: this.logDedupeState,
890
+ key,
891
+ sig,
892
+ nowMs: now(),
893
+ windowMs,
894
+ });
861
895
  }
862
896
 
863
897
  private logInfoDedup(
@@ -954,39 +988,64 @@ class BncrBridgeRuntime {
954
988
  );
955
989
  }
956
990
 
991
+ private buildStatusWorkerRuntime() {
992
+ return {
993
+ workers: this.channelAccountWorkers,
994
+ bridgeId: this.bridgeId,
995
+ hooks: {
996
+ isOnline: (accountId: string) => this.isOnline(accountId),
997
+ hasRecentInboundReachability: (accountId: string) =>
998
+ this.hasRecentInboundReachability(accountId),
999
+ getLastActivityAt: (accountId: string, previous: Record<string, any>) =>
1000
+ this.lastActivityByAccount.get(accountId) ||
1001
+ this.lastInboundByAccount.get(accountId) ||
1002
+ this.lastOutboundByAccount.get(accountId) ||
1003
+ previous?.lastEventAt ||
1004
+ null,
1005
+ getActiveConnectionKey: (accountId: string) =>
1006
+ this.activeConnectionByAccount.get(accountId) || null,
1007
+ getActiveConnections: (accountId: string) =>
1008
+ Array.from(this.connections.values())
1009
+ .filter((c) => c.accountId === accountId)
1010
+ .map((c) => ({
1011
+ connId: c.connId,
1012
+ clientId: c.clientId,
1013
+ inboundOnly: c.inboundOnly === true,
1014
+ outboundReady: c.outboundReady === true,
1015
+ preferredForOutbound: c.preferredForOutbound === true,
1016
+ })),
1017
+ buildStatusMeta: (accountId: string) => this.buildStatusMeta(accountId),
1018
+ logInfo: (scope: string | undefined, message: string, options?: { debugOnly?: boolean }) =>
1019
+ this.logInfo(scope, message, options),
1020
+ logInfoDedup: (
1021
+ scope: string | undefined,
1022
+ message: string,
1023
+ options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
1024
+ ) => this.logInfoDedup(scope, message, options),
1025
+ },
1026
+ };
1027
+ }
1028
+
957
1029
  private clearChannelAccountWorker(accountId: string, reason: string) {
958
- const worker = this.channelAccountWorkers.get(accountId);
959
- if (!worker) return false;
960
- worker.finish(reason);
961
- this.logInfo(
962
- 'health',
963
- `status-worker cleared ${JSON.stringify({ bridge: this.bridgeId, accountId, reason })}`,
964
- { debugOnly: true },
965
- );
966
- return true;
1030
+ return clearBncrStatusWorker(this.buildStatusWorkerRuntime(), accountId, reason);
967
1031
  }
968
1032
 
969
1033
  private clearAllChannelAccountWorkers(reason: string) {
970
- for (const accountId of Array.from(this.channelAccountWorkers.keys())) {
971
- this.clearChannelAccountWorker(accountId, reason);
972
- }
1034
+ clearAllBncrStatusWorkers(this.buildStatusWorkerRuntime(), reason);
973
1035
  }
974
1036
 
975
1037
  private captureDriftSnapshot(
976
1038
  summary: ReturnType<BncrBridgeRuntime['buildRegisterTraceSummary']>,
977
1039
  ) {
978
- this.lastDriftSnapshot = {
1040
+ this.lastDriftSnapshot = buildRegisterDriftSnapshot({
979
1041
  capturedAt: now(),
980
1042
  registerCount: this.registerCount,
981
1043
  apiGeneration: this.apiGeneration,
982
- postWarmupRegisterCount: summary.postWarmupRegisterCount,
1044
+ summary,
983
1045
  apiInstanceId: this.lastApiInstanceId,
984
1046
  registryFingerprint: this.lastRegistryFingerprint,
985
- dominantBucket: summary.dominantBucket,
986
- sourceBuckets: { ...summary.sourceBuckets },
987
- traceWindowSize: this.registerTraceRecent.length,
988
- traceRecent: this.registerTraceRecent.map((trace) => ({ ...trace })),
989
- };
1047
+ traceRecent: this.registerTraceRecent,
1048
+ });
990
1049
  this.scheduleSave();
991
1050
  }
992
1051
 
@@ -1026,9 +1085,7 @@ class BncrBridgeRuntime {
1026
1085
  .map((line) => line.trim())
1027
1086
  .filter(Boolean)
1028
1087
  .join(' <- ');
1029
- const stackBucket = classifyRegisterTraceFromStack(stack);
1030
-
1031
- const trace = {
1088
+ const trace = buildRegisterTraceEntry({
1032
1089
  ts,
1033
1090
  bridgeId: this.bridgeId,
1034
1091
  gatewayPid: this.gatewayPid,
@@ -1040,11 +1097,8 @@ class BncrBridgeRuntime {
1040
1097
  source: this.pluginSource,
1041
1098
  pluginVersion: this.pluginVersion,
1042
1099
  stack,
1043
- stackBucket,
1044
- };
1045
- this.registerTraceRecent.push(trace);
1046
- if (this.registerTraceRecent.length > 12)
1047
- this.registerTraceRecent.splice(0, this.registerTraceRecent.length - 12);
1100
+ });
1101
+ appendBoundedRegisterTrace(this.registerTraceRecent, trace, 12);
1048
1102
 
1049
1103
  const summary = this.buildRegisterTraceSummary();
1050
1104
  if (summary.postWarmupRegisterCount > 0) this.captureDriftSnapshot(summary);
@@ -1147,11 +1201,29 @@ class BncrBridgeRuntime {
1147
1201
  return matchesTransferOwnerFromRuntime(params);
1148
1202
  }
1149
1203
 
1204
+ 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
+ };
1219
+ }
1220
+
1150
1221
  private buildExtendedDiagnostics(accountId: string) {
1151
1222
  const acc = normalizeAccountId(accountId);
1152
1223
  const diagnostics = this.buildIntegratedDiagnostics(acc) as Record<string, any>;
1153
1224
  return buildExtendedDiagnosticsFromRuntime({
1154
1225
  diagnostics,
1226
+ runtimeSurface: this.buildRuntimeSurfaceDiagnostics(),
1155
1227
  register: {
1156
1228
  bridgeId: this.bridgeId,
1157
1229
  gatewayPid: this.gatewayPid,
@@ -1745,7 +1817,9 @@ class BncrBridgeRuntime {
1745
1817
  const primaryKey = this.activeConnectionByAccount.get(acc);
1746
1818
  const primary = primaryKey ? this.connections.get(primaryKey) : null;
1747
1819
 
1748
- const isEligible = (conn: BncrConnection | null | undefined): conn is BncrConnection & {
1820
+ const isEligible = (
1821
+ conn: BncrConnection | null | undefined,
1822
+ ): conn is BncrConnection & {
1749
1823
  outboundReadyUntil?: number;
1750
1824
  preferredForOutboundUntil?: number;
1751
1825
  inboundOnly?: boolean;
@@ -1790,10 +1864,13 @@ class BncrBridgeRuntime {
1790
1864
  const sb = candidateScore(b);
1791
1865
  if (sb.preferred !== sa.preferred) return sb.preferred - sa.preferred;
1792
1866
  if (sb.ready !== sa.ready) return sb.ready - sa.ready;
1793
- if (sa.recentTimeoutPenalty !== sb.recentTimeoutPenalty) return sa.recentTimeoutPenalty - sb.recentTimeoutPenalty;
1794
- if (sa.pushFailureScore !== sb.pushFailureScore) return sa.pushFailureScore - sb.pushFailureScore;
1867
+ if (sa.recentTimeoutPenalty !== sb.recentTimeoutPenalty)
1868
+ return sa.recentTimeoutPenalty - sb.recentTimeoutPenalty;
1869
+ if (sa.pushFailureScore !== sb.pushFailureScore)
1870
+ return sa.pushFailureScore - sb.pushFailureScore;
1795
1871
  if (sb.lastAckOkAt !== sa.lastAckOkAt) return sb.lastAckOkAt - sa.lastAckOkAt;
1796
- if (sa.lastPushTimeoutAt !== sb.lastPushTimeoutAt) return sa.lastPushTimeoutAt - sb.lastPushTimeoutAt;
1872
+ if (sa.lastPushTimeoutAt !== sb.lastPushTimeoutAt)
1873
+ return sa.lastPushTimeoutAt - sb.lastPushTimeoutAt;
1797
1874
  if (sb.recentInbound !== sa.recentInbound) return sb.recentInbound - sa.recentInbound;
1798
1875
  if (sb.lastSeenAt !== sa.lastSeenAt) return sb.lastSeenAt - sa.lastSeenAt;
1799
1876
  return sb.connectedAt - sa.connectedAt;
@@ -1841,7 +1918,9 @@ class BncrBridgeRuntime {
1841
1918
  const t = now();
1842
1919
  const connIds = new Set<string>();
1843
1920
 
1844
- const isEligible = (conn: BncrConnection | null | undefined): conn is BncrConnection & {
1921
+ const isEligible = (
1922
+ conn: BncrConnection | null | undefined,
1923
+ ): conn is BncrConnection & {
1845
1924
  outboundReadyUntil?: number;
1846
1925
  preferredForOutboundUntil?: number;
1847
1926
  inboundOnly?: boolean;
@@ -1889,10 +1968,13 @@ class BncrBridgeRuntime {
1889
1968
  const sb = candidateScore(b);
1890
1969
  if (sb.preferred !== sa.preferred) return sb.preferred - sa.preferred;
1891
1970
  if (sb.ready !== sa.ready) return sb.ready - sa.ready;
1892
- if (sa.recentTimeoutPenalty !== sb.recentTimeoutPenalty) return sa.recentTimeoutPenalty - sb.recentTimeoutPenalty;
1893
- if (sa.pushFailureScore !== sb.pushFailureScore) return sa.pushFailureScore - sb.pushFailureScore;
1971
+ if (sa.recentTimeoutPenalty !== sb.recentTimeoutPenalty)
1972
+ return sa.recentTimeoutPenalty - sb.recentTimeoutPenalty;
1973
+ if (sa.pushFailureScore !== sb.pushFailureScore)
1974
+ return sa.pushFailureScore - sb.pushFailureScore;
1894
1975
  if (sb.lastAckOkAt !== sa.lastAckOkAt) return sb.lastAckOkAt - sa.lastAckOkAt;
1895
- if (sa.lastPushTimeoutAt !== sb.lastPushTimeoutAt) return sa.lastPushTimeoutAt - sb.lastPushTimeoutAt;
1976
+ if (sa.lastPushTimeoutAt !== sb.lastPushTimeoutAt)
1977
+ return sa.lastPushTimeoutAt - sb.lastPushTimeoutAt;
1896
1978
  if (sb.recentInbound !== sa.recentInbound) return sb.recentInbound - sa.recentInbound;
1897
1979
  if (sb.lastSeenAt !== sa.lastSeenAt) return sb.lastSeenAt - sa.lastSeenAt;
1898
1980
  return sb.connectedAt - sa.connectedAt;
@@ -1976,10 +2058,7 @@ class BncrBridgeRuntime {
1976
2058
 
1977
2059
  private tryAdoptTransferOwner(args: {
1978
2060
  accountId: string;
1979
- transfer:
1980
- | FileSendTransferState
1981
- | FileRecvTransferState
1982
- | undefined;
2061
+ transfer: FileSendTransferState | FileRecvTransferState | undefined;
1983
2062
  connId: string;
1984
2063
  clientId?: string;
1985
2064
  }): boolean {
@@ -2111,6 +2190,7 @@ class BncrBridgeRuntime {
2111
2190
  this.recordOutboxPrePushFailure({
2112
2191
  entry: args.entry,
2113
2192
  lastError: args.guard.lastError,
2193
+ persist: true,
2114
2194
  });
2115
2195
  if (args.guard.reason === 'media-url-missing') {
2116
2196
  this.logOutboxPushFailure({
@@ -2126,9 +2206,12 @@ class BncrBridgeRuntime {
2126
2206
  messageId: args.entry.messageId,
2127
2207
  accountId: args.entry.accountId,
2128
2208
  kind: 'file-transfer',
2129
- reason: args.guard.reason === 'no-gateway-context' ? 'no-gateway-context' : 'no-active-connection',
2209
+ reason:
2210
+ args.guard.reason === 'no-gateway-context' ? 'no-gateway-context' : 'no-active-connection',
2130
2211
  recentInboundReachable:
2131
- args.guard.reason === 'no-active-connection' ? args.guard.recentInboundReachable : undefined,
2212
+ args.guard.reason === 'no-active-connection'
2213
+ ? args.guard.recentInboundReachable
2214
+ : undefined,
2132
2215
  });
2133
2216
  }
2134
2217
 
@@ -2421,12 +2504,24 @@ class BncrBridgeRuntime {
2421
2504
  routeSelection: selection,
2422
2505
  });
2423
2506
  if (!guard.ok) {
2507
+ this.recordOutboxPrePushFailure({
2508
+ entry,
2509
+ lastError:
2510
+ guard.reason === 'no-gateway-context'
2511
+ ? 'gateway context unavailable'
2512
+ : 'no active bncr client',
2513
+ persist: true,
2514
+ });
2424
2515
  this.logOutboxPushSkip({
2425
2516
  messageId: entry.messageId,
2426
2517
  accountId: entry.accountId,
2427
2518
  reason: guard.reason,
2428
2519
  recentInboundReachable:
2429
2520
  guard.reason === 'no-active-connection' ? guard.recentInboundReachable : undefined,
2521
+ routeReason: selection.routeReason,
2522
+ connIds: selection.connIds,
2523
+ ownerConnId: selection.ownerConnId,
2524
+ ownerClientId: owner?.clientId,
2430
2525
  });
2431
2526
  return false;
2432
2527
  }
@@ -2458,10 +2553,24 @@ class BncrBridgeRuntime {
2458
2553
  kind?: 'file-transfer';
2459
2554
  reason: string;
2460
2555
  recentInboundReachable?: boolean;
2556
+ routeReason?: string;
2557
+ connIds?: Iterable<string>;
2558
+ ownerConnId?: string;
2559
+ ownerClientId?: string;
2461
2560
  }) {
2561
+ this.logInfo(
2562
+ 'outbox push skip',
2563
+ `mid=${args.messageId}|q=${this.outbox.size}|reason=${args.reason}${args.kind ? `|kind=${args.kind}` : ''}`,
2564
+ );
2462
2565
  this.logInfo(
2463
2566
  'outbox',
2464
- `push-skip ${JSON.stringify(buildOutboxPushSkipDebugInfo(args))}`,
2567
+ `push-skip ${JSON.stringify(
2568
+ buildOutboxPushSkipDebugInfo({
2569
+ ...args,
2570
+ activeConnectionCount: this.activeConnectionCount(args.accountId),
2571
+ connections: this.connections.values(),
2572
+ }),
2573
+ )}`,
2465
2574
  { debugOnly: true },
2466
2575
  );
2467
2576
  }
@@ -2492,11 +2601,9 @@ class BncrBridgeRuntime {
2492
2601
  retryable?: boolean;
2493
2602
  lastError?: string;
2494
2603
  }) {
2495
- this.logInfo(
2496
- 'outbox',
2497
- `push-fail ${JSON.stringify(buildPushFailureDebugInfo(args))}`,
2498
- { debugOnly: true },
2499
- );
2604
+ this.logInfo('outbox', `push-fail ${JSON.stringify(buildPushFailureDebugInfo(args))}`, {
2605
+ debugOnly: true,
2606
+ });
2500
2607
  }
2501
2608
 
2502
2609
  private logOutboxPushOkSummary(messageId: string) {
@@ -2549,14 +2656,18 @@ class BncrBridgeRuntime {
2549
2656
  sessionKey: args.entry.sessionKey,
2550
2657
  to: formatDisplayScope(args.entry.route),
2551
2658
  kind:
2552
- isPlainObject(args.entry.payload?._meta) && args.entry.payload?._meta?.kind === 'file-transfer'
2659
+ isPlainObject(args.entry.payload?._meta) &&
2660
+ args.entry.payload?._meta?.kind === 'file-transfer'
2553
2661
  ? 'file-transfer'
2554
2662
  : undefined,
2555
2663
  requireAck: args.requireAck,
2556
2664
  ackResult: args.ackResult,
2557
2665
  ackStage: 'message',
2558
2666
  ackOutcome: args.ackResult,
2559
- reason: args.ackResult === 'timeout' ? OUTBOUND_TERMINAL_REASON.PUSH_ACK_TIMEOUT : 'message-acked',
2667
+ reason:
2668
+ args.ackResult === 'timeout'
2669
+ ? OUTBOUND_TERMINAL_REASON.PUSH_ACK_TIMEOUT
2670
+ : 'message-acked',
2560
2671
  ackTimeoutMs: typeof args.ackTimeoutMs === 'number' ? args.ackTimeoutMs : undefined,
2561
2672
  adaptiveAckTimeoutEnabled: ADAPTIVE_ACK_TIMEOUT_DEFAULT_ENABLED,
2562
2673
  onlineNow: args.onlineNow,
@@ -2581,16 +2692,13 @@ class BncrBridgeRuntime {
2581
2692
  localNextDelay: number | null;
2582
2693
  ackTimeoutMs?: number | null;
2583
2694
  }) {
2584
- this.logOutboxAckSummary(
2585
- args.requireAck ? 'outbox ack timeout' : 'outbox ack retry',
2586
- {
2587
- messageId: args.entry.messageId,
2588
- connId: args.entry.lastPushConnId,
2589
- clientId: args.entry.lastPushClientId,
2590
- err: args.requireAck ? undefined : args.entry.lastError,
2591
- waitMs: args.requireAck ? args.ackTimeoutMs : undefined,
2592
- },
2593
- );
2695
+ this.logOutboxAckSummary(args.requireAck ? 'outbox ack timeout' : 'outbox ack retry', {
2696
+ messageId: args.entry.messageId,
2697
+ connId: args.entry.lastPushConnId,
2698
+ clientId: args.entry.lastPushClientId,
2699
+ err: args.requireAck ? undefined : args.entry.lastError,
2700
+ waitMs: args.requireAck ? args.ackTimeoutMs : undefined,
2701
+ });
2594
2702
  this.logInfo(
2595
2703
  'outbox',
2596
2704
  `retry-reroute ${JSON.stringify(
@@ -2626,12 +2734,7 @@ class BncrBridgeRuntime {
2626
2734
  stale: boolean,
2627
2735
  result: { ok: true; movedToDeadLetter?: true; willRetry?: true },
2628
2736
  ) {
2629
- respond(
2630
- true,
2631
- stale
2632
- ? { ...result, stale: true, staleAccepted: true }
2633
- : result,
2634
- );
2737
+ respond(true, stale ? { ...result, stale: true, staleAccepted: true } : result);
2635
2738
  }
2636
2739
 
2637
2740
  private prepareAckHandling(args: {
@@ -2733,19 +2836,18 @@ class BncrBridgeRuntime {
2733
2836
  outboundReady: true,
2734
2837
  preferredForOutbound: true,
2735
2838
  });
2736
- const ackAt = now();
2839
+ const telemetryPatch = buildBncrAckOkTelemetryPatch({
2840
+ entry: args.entry,
2841
+ ackAt: now(),
2842
+ defaultAckTimeoutMs: PUSH_ACK_TIMEOUT_MS,
2843
+ });
2844
+ const { ackAt, ackQueueLatencyMs, ackPushLatencyMs, lateAccepted } = telemetryPatch;
2737
2845
  this.lastAckOkByAccount.set(args.accountId, ackAt);
2738
- const ackQueueLatencyMs = Math.max(0, ackAt - finiteNumberOr(args.entry.createdAt, ackAt));
2739
- const ackPushLatencyMs =
2740
- typeof args.entry.lastPushAt === 'number'
2741
- ? Math.max(0, ackAt - args.entry.lastPushAt)
2742
- : null;
2743
2846
  this.lastAckQueueLatencyMsByAccount.set(args.accountId, ackQueueLatencyMs);
2744
2847
  if (typeof ackPushLatencyMs === 'number') {
2745
2848
  this.lastAckPushLatencyMsByAccount.set(args.accountId, ackPushLatencyMs);
2746
2849
  }
2747
- const lateAccepted = args.entry.awaitingRetryPush === true;
2748
- if (lateAccepted) {
2850
+ if (telemetryPatch.shouldResetAdaptiveAckRecovery) {
2749
2851
  this.adaptiveAckRecoveryOkCountByAccount.set(args.accountId, 0);
2750
2852
  this.lateAckOkCountByAccount.set(
2751
2853
  args.accountId,
@@ -2758,7 +2860,7 @@ class BncrBridgeRuntime {
2758
2860
  }
2759
2861
  args.entry.awaitingRetryPush = false;
2760
2862
  args.entry.lastError = undefined;
2761
- } else if (typeof ackPushLatencyMs === 'number' && ackPushLatencyMs <= PUSH_ACK_TIMEOUT_MS) {
2863
+ } else if (telemetryPatch.shouldIncrementAdaptiveAckRecovery) {
2762
2864
  this.adaptiveAckRecoveryOkCountByAccount.set(
2763
2865
  args.accountId,
2764
2866
  this.getCounter(this.adaptiveAckRecoveryOkCountByAccount, args.accountId) + 1,
@@ -2800,16 +2902,18 @@ class BncrBridgeRuntime {
2800
2902
  clientId?: string;
2801
2903
  error: string;
2802
2904
  }) {
2803
- args.entry.nextAttemptAt = now() + 1_000;
2804
- args.entry.lastError = args.error;
2805
- args.entry.awaitingRetryPush = true;
2806
- this.outbox.set(args.messageId, args.entry);
2905
+ const nextEntry = buildBncrAckRetryEntryPatch({
2906
+ entry: args.entry,
2907
+ error: args.error,
2908
+ nextAttemptAt: now() + 1_000,
2909
+ });
2910
+ this.outbox.set(args.messageId, nextEntry);
2807
2911
  this.scheduleSave();
2808
2912
  this.logOutboxAckSummary('outbox ack retry', {
2809
2913
  messageId: args.messageId,
2810
2914
  connId: args.connId,
2811
2915
  clientId: args.clientId,
2812
- err: args.entry.lastError,
2916
+ err: nextEntry.lastError,
2813
2917
  });
2814
2918
  }
2815
2919
 
@@ -2837,7 +2941,7 @@ class BncrBridgeRuntime {
2837
2941
  entry,
2838
2942
  });
2839
2943
  this.respondAckResult(respond, staleObserved.stale, { ok: true });
2840
- this.flushPushQueue({
2944
+ this.flushPushQueueBestEffort({
2841
2945
  accountId,
2842
2946
  trigger: OUTBOUND_FLUSH_TRIGGER.ACK_OK,
2843
2947
  reason: OUTBOUND_FLUSH_REASON.MESSAGE_ACKED,
@@ -2978,7 +3082,15 @@ class BncrBridgeRuntime {
2978
3082
  inboundOnly: boolean;
2979
3083
  context: GatewayRequestHandlerOptions['context'];
2980
3084
  }) {
2981
- const { accountId, connId, clientId, outboundReady, preferredForOutbound, inboundOnly, context } = args;
3085
+ const {
3086
+ accountId,
3087
+ connId,
3088
+ clientId,
3089
+ outboundReady,
3090
+ preferredForOutbound,
3091
+ inboundOnly,
3092
+ context,
3093
+ } = args;
2982
3094
  this.refreshAcceptedFileTransferLiveState({
2983
3095
  accountId,
2984
3096
  connId,
@@ -3017,19 +3129,23 @@ class BncrBridgeRuntime {
3017
3129
  recentInboundReachable: boolean;
3018
3130
  event: string;
3019
3131
  }) {
3020
- this.logInfo(
3021
- 'outbox',
3022
- `push ${JSON.stringify(buildOutboxPushOkDebugInfo(args))}`,
3023
- { debugOnly: true },
3024
- );
3132
+ this.logInfo('outbox', `push ${JSON.stringify(buildOutboxPushOkDebugInfo(args))}`, {
3133
+ debugOnly: true,
3134
+ });
3025
3135
  }
3026
3136
 
3027
3137
  private recordOutboxPrePushFailure(args: {
3028
3138
  entry: OutboxEntry;
3029
3139
  lastError: string;
3140
+ persist?: boolean;
3030
3141
  }) {
3031
- args.entry.lastError = args.lastError;
3032
- this.outbox.set(args.entry.messageId, args.entry);
3142
+ const nextEntry = buildBncrOutboxFailureEntryPatch({
3143
+ entry: args.entry,
3144
+ lastError: args.lastError,
3145
+ });
3146
+ Object.assign(args.entry, nextEntry);
3147
+ this.outbox.set(nextEntry.messageId, args.entry);
3148
+ if (args.persist) this.scheduleSave();
3033
3149
  }
3034
3150
 
3035
3151
  private recordOutboxPushFailure(args: {
@@ -3038,8 +3154,12 @@ class BncrBridgeRuntime {
3038
3154
  fallbackError: string;
3039
3155
  persist?: boolean;
3040
3156
  }) {
3041
- args.entry.lastError = asString((args.error as any)?.message || args.error || args.fallbackError);
3042
- this.outbox.set(args.entry.messageId, args.entry);
3157
+ const nextEntry = buildBncrOutboxFailureEntryPatch({
3158
+ entry: args.entry,
3159
+ lastError: asString((args.error as any)?.message || args.error || args.fallbackError),
3160
+ });
3161
+ Object.assign(args.entry, nextEntry);
3162
+ this.outbox.set(nextEntry.messageId, args.entry);
3043
3163
  if (args.persist) this.scheduleSave();
3044
3164
  }
3045
3165
 
@@ -3050,23 +3170,19 @@ class BncrBridgeRuntime {
3050
3170
  ownerClientId?: string;
3051
3171
  clearLastError?: boolean;
3052
3172
  }) {
3053
- const connIds = Array.from(args.connIds);
3054
- args.entry.lastPushAt = now();
3055
- args.entry.lastPushConnId =
3056
- args.ownerConnId || (connIds.length === 1 ? connIds[0] : undefined);
3057
- args.entry.lastPushClientId = args.ownerClientId;
3058
- args.entry.awaitingRetryPush = false;
3059
- if (!Array.isArray(args.entry.routeAttemptConnIds)) args.entry.routeAttemptConnIds = [];
3060
- if (
3061
- args.entry.lastPushConnId &&
3062
- !args.entry.routeAttemptConnIds.includes(args.entry.lastPushConnId)
3063
- ) {
3064
- args.entry.routeAttemptConnIds.push(args.entry.lastPushConnId);
3065
- }
3066
- if (args.clearLastError) args.entry.lastError = undefined;
3067
- this.outbox.set(args.entry.messageId, args.entry);
3068
- this.lastOutboundByAccount.set(args.entry.accountId, args.entry.lastPushAt);
3069
- this.markActivity(args.entry.accountId, args.entry.lastPushAt);
3173
+ const pushedAt = now();
3174
+ const nextEntry = buildBncrOutboxPushSuccessEntryPatch({
3175
+ entry: args.entry,
3176
+ connIds: args.connIds,
3177
+ pushedAt,
3178
+ ownerConnId: args.ownerConnId,
3179
+ ownerClientId: args.ownerClientId,
3180
+ clearLastError: args.clearLastError,
3181
+ });
3182
+ Object.assign(args.entry, nextEntry);
3183
+ this.outbox.set(nextEntry.messageId, args.entry);
3184
+ this.lastOutboundByAccount.set(nextEntry.accountId, pushedAt);
3185
+ this.markActivity(nextEntry.accountId, pushedAt);
3070
3186
  this.scheduleSave();
3071
3187
  }
3072
3188
 
@@ -3093,49 +3209,107 @@ class BncrBridgeRuntime {
3093
3209
  this.pushTimer = setTimeout(() => {
3094
3210
  this.pushTimer = null;
3095
3211
  if (this.stopped) return;
3096
- void this.flushPushQueue({
3212
+ this.flushPushQueueBestEffort({
3097
3213
  trigger: OUTBOUND_FLUSH_TRIGGER.TIMER,
3098
3214
  reason: OUTBOUND_FLUSH_REASON.SCHEDULED_DRAIN,
3099
3215
  });
3100
3216
  }, delay);
3101
3217
  }
3102
3218
 
3219
+ private flushPushQueueBestEffort(args?: {
3220
+ accountId?: string;
3221
+ trigger?: string;
3222
+ reason?: string;
3223
+ }) {
3224
+ void this.flushPushQueue(args)
3225
+ .then(() => {
3226
+ this.pushDrainExceptionRetryCount = 0;
3227
+ })
3228
+ .catch((error) => {
3229
+ const accountId = args?.accountId ? normalizeAccountId(args.accountId) : '';
3230
+ const reason = asString(args?.reason || args?.trigger || 'flush-error');
3231
+ const err = asString((error as any)?.message || error || 'flush-error');
3232
+ const nextRetryCount = this.pushDrainExceptionRetryCount + 1;
3233
+ const willRetry = nextRetryCount <= PUSH_DRAIN_EXCEPTION_RETRY_LIMIT;
3234
+ this.pushDrainExceptionRetryCount = nextRetryCount;
3235
+ this.logError(
3236
+ 'outbox drain fail',
3237
+ `accountId=${accountId || '-'}|reason=${reason}|err=${err}|retry=${willRetry ? nextRetryCount : 'false'}|limit=${PUSH_DRAIN_EXCEPTION_RETRY_LIMIT}`,
3238
+ );
3239
+ if (willRetry) {
3240
+ this.schedulePushDrain(PUSH_DRAIN_EXCEPTION_RETRY_DELAY_MS);
3241
+ }
3242
+ });
3243
+ }
3244
+
3103
3245
  private isOutboundAckRequired(accountId?: string) {
3104
- try {
3105
- const cfg = getOpenClawRuntimeConfig(this.api);
3106
- const channelCfg = (cfg as any)?.channels?.[CHANNEL_ID];
3107
- const accountCfg =
3108
- accountId && channelCfg?.accounts && typeof channelCfg.accounts === 'object'
3109
- ? (channelCfg.accounts as Record<string, any>)[normalizeAccountId(accountId)]
3110
- : null;
3111
- const scoped = accountCfg?.outboundRequireAck;
3112
- const global = channelCfg?.outboundRequireAck;
3113
- if (typeof scoped === 'boolean') return scoped;
3114
- if (typeof global === 'boolean') return global;
3115
- return true;
3116
- } catch {
3117
- return true;
3118
- }
3246
+ return resolveBncrOutboundAckRequired({ api: this.api, accountId });
3119
3247
  }
3120
3248
 
3121
3249
  private buildRuntimeFlags(accountId?: string) {
3122
- let ackPolicySource: 'channel' | 'default' = 'default';
3123
- try {
3124
- const cfg = getOpenClawRuntimeConfig(this.api);
3125
- const global = (cfg as any)?.channels?.[CHANNEL_ID]?.outboundRequireAck;
3126
- if (typeof global === 'boolean') ackPolicySource = 'channel';
3127
- } catch {
3128
- // keep default source
3129
- }
3130
- return {
3131
- outboundRequireAck: this.isOutboundAckRequired(accountId),
3132
- ackPolicySource,
3133
- messageAckTimeoutMs: this.resolveMessageAckTimeoutMs(accountId),
3250
+ return buildBncrRuntimeFlags({
3251
+ api: this.api,
3252
+ accountId,
3253
+ resolveMessageAckTimeoutMs: (acc?: string) => this.resolveMessageAckTimeoutMs(acc),
3134
3254
  adaptiveAckTimeoutEnabled: ADAPTIVE_ACK_TIMEOUT_DEFAULT_ENABLED,
3135
3255
  defaultMessageAckTimeoutMs: PUSH_ACK_TIMEOUT_MS,
3136
3256
  fileAckTimeoutMs: FILE_ACK_TIMEOUT_MS,
3137
3257
  debugVerbose: BNCR_DEBUG_VERBOSE,
3138
- };
3258
+ });
3259
+ }
3260
+
3261
+ private getAccountPendingOutboxEntries(accountId: string) {
3262
+ const acc = normalizeAccountId(accountId);
3263
+ return Array.from(this.outbox.values()).filter((entry) => entry.accountId === acc);
3264
+ }
3265
+
3266
+ private maybeLogOutboxDrainStuck(args: {
3267
+ accountId: string;
3268
+ trigger: string;
3269
+ reason: string;
3270
+ }) {
3271
+ const acc = normalizeAccountId(args.accountId);
3272
+ const startedAt = this.pushDrainRunningSinceByAccount.get(acc) || 0;
3273
+ if (!startedAt) return;
3274
+
3275
+ const t = now();
3276
+ const runningMs = Math.max(0, t - startedAt);
3277
+ if (runningMs < PUSH_DRAIN_STUCK_WARN_MS) return;
3278
+
3279
+ const lastWarnedAt = this.pushDrainStuckWarnedAtByAccount.get(acc) || 0;
3280
+ if (lastWarnedAt && t - lastWarnedAt < PUSH_DRAIN_STUCK_WARN_MS) return;
3281
+
3282
+ const pendingEntries = this.getAccountPendingOutboxEntries(acc);
3283
+ const pending = pendingEntries.length;
3284
+ if (!pending) return;
3285
+
3286
+ this.pushDrainStuckWarnedAtByAccount.set(acc, t);
3287
+ this.logWarn(
3288
+ 'outbox drain stuck',
3289
+ `accountId=${acc}|pending=${pending}|runningMs=${runningMs}|waiters=${this.messageAckWaiters.size}/${this.fileAckWaiters.size}`,
3290
+ );
3291
+ this.logInfo(
3292
+ 'outbox',
3293
+ `drain-stuck ${JSON.stringify(
3294
+ buildOutboxDrainStuckDebugInfo({
3295
+ bridgeId: this.bridgeId,
3296
+ accountId: acc,
3297
+ reason: args.reason,
3298
+ trigger: args.trigger,
3299
+ outboxSize: this.outbox.size,
3300
+ pending,
3301
+ runningMs,
3302
+ runningSince: startedAt,
3303
+ hasGatewayContext: Boolean(this.gatewayContext),
3304
+ activeConnectionCount: this.activeConnectionCount(acc),
3305
+ messageAckWaiters: this.messageAckWaiters.size,
3306
+ fileAckWaiters: this.fileAckWaiters.size,
3307
+ pendingEntries,
3308
+ connections: this.connections.values(),
3309
+ }),
3310
+ )}`,
3311
+ { debugOnly: true },
3312
+ );
3139
3313
  }
3140
3314
 
3141
3315
  private async flushPushQueue(args?: {
@@ -3195,7 +3369,28 @@ class BncrBridgeRuntime {
3195
3369
  let globalNextDelay: number | null = null;
3196
3370
 
3197
3371
  for (const acc of targetAccounts) {
3198
- if (!acc || this.pushDrainRunningAccounts.has(acc)) continue;
3372
+ if (!acc) continue;
3373
+ if (this.pushDrainRunningAccounts.has(acc)) {
3374
+ this.logInfo(
3375
+ 'outbox',
3376
+ `drain-skip ${JSON.stringify(
3377
+ buildOutboxDrainSkipDebugInfo({
3378
+ bridgeId: this.bridgeId,
3379
+ accountId: acc,
3380
+ reason: 'already-running',
3381
+ outboxSize: this.outbox.size,
3382
+ trigger,
3383
+ }),
3384
+ )}`,
3385
+ { debugOnly: true },
3386
+ );
3387
+ this.maybeLogOutboxDrainStuck({
3388
+ accountId: acc,
3389
+ trigger,
3390
+ reason: reason || 'already-running',
3391
+ });
3392
+ continue;
3393
+ }
3199
3394
  const online = this.isOnline(acc);
3200
3395
  const recentInboundReachable = this.hasRecentInboundReachability(acc);
3201
3396
  this.logInfo(
@@ -3212,6 +3407,8 @@ class BncrBridgeRuntime {
3212
3407
  { debugOnly: true },
3213
3408
  );
3214
3409
  this.pushDrainRunningAccounts.add(acc);
3410
+ this.pushDrainRunningSinceByAccount.set(acc, now());
3411
+ this.pushDrainStuckWarnedAtByAccount.delete(acc);
3215
3412
  try {
3216
3413
  let localNextDelay: number | null = null;
3217
3414
  let processedThisRun = 0;
@@ -3219,7 +3416,10 @@ class BncrBridgeRuntime {
3219
3416
 
3220
3417
  while (true) {
3221
3418
  if (this.stopped) break;
3222
- if (processedThisRun > 0 && now() - accountDrainStartedAt >= PUSH_DRAIN_ACCOUNT_TIME_BUDGET_MS) {
3419
+ if (
3420
+ processedThisRun > 0 &&
3421
+ now() - accountDrainStartedAt >= PUSH_DRAIN_ACCOUNT_TIME_BUDGET_MS
3422
+ ) {
3223
3423
  localNextDelay = updateMinOutboxDelay(localNextDelay, 0);
3224
3424
  this.logInfo(
3225
3425
  'outbox',
@@ -3286,14 +3486,63 @@ class BncrBridgeRuntime {
3286
3486
 
3287
3487
  const onlineNow = this.isOnline(acc);
3288
3488
  const recentInboundReachable = this.hasRecentInboundReachability(acc);
3289
- const pushed = await this.tryPushEntry(entry);
3489
+ let pushed = false;
3490
+ try {
3491
+ pushed = await this.tryPushEntry(entry);
3492
+ } catch (error) {
3493
+ const meta = isPlainObject(entry.payload?._meta) ? entry.payload._meta : null;
3494
+ if (meta?.kind === 'file-transfer') {
3495
+ this.handleFileTransferPushFailure({
3496
+ entry,
3497
+ error,
3498
+ });
3499
+ } else {
3500
+ this.handleTextPushFailure({
3501
+ entry,
3502
+ error,
3503
+ });
3504
+ }
3505
+ pushed = false;
3506
+ }
3290
3507
  processedThisRun += 1;
3291
3508
  if (pushed) {
3292
3509
  const requireAck = this.isOutboundAckRequired(acc);
3293
3510
  const ackTimeoutMs = requireAck ? this.resolveMessageAckTimeoutMs(acc) : null;
3294
3511
  let ackResult: 'acked' | 'timeout' = requireAck ? 'timeout' : 'acked';
3295
3512
  if (onlineNow && requireAck) {
3296
- ackResult = await this.waitForMessageAck(entry.messageId, ackTimeoutMs || PUSH_ACK_TIMEOUT_MS);
3513
+ this.logInfo(
3514
+ 'outbox',
3515
+ `ack wait-start ${JSON.stringify(
3516
+ buildOutboxAckDebugInfo({
3517
+ messageId: entry.messageId,
3518
+ accountId: entry.accountId,
3519
+ sessionKey: entry.sessionKey,
3520
+ to: formatDisplayScope(entry.route),
3521
+ kind:
3522
+ isPlainObject(entry.payload?._meta) &&
3523
+ entry.payload?._meta?.kind === 'file-transfer'
3524
+ ? 'file-transfer'
3525
+ : undefined,
3526
+ requireAck,
3527
+ ackResult: 'timeout',
3528
+ ackStage: 'message',
3529
+ ackOutcome: 'waiting',
3530
+ ackTimeoutMs: ackTimeoutMs || PUSH_ACK_TIMEOUT_MS,
3531
+ adaptiveAckTimeoutEnabled: ADAPTIVE_ACK_TIMEOUT_DEFAULT_ENABLED,
3532
+ onlineNow,
3533
+ recentInboundReachable,
3534
+ connIds: entry.lastPushConnId ? [entry.lastPushConnId] : [],
3535
+ ownerConnId: entry.lastPushConnId,
3536
+ ownerClientId: entry.lastPushClientId,
3537
+ event: BNCR_PUSH_EVENT,
3538
+ }),
3539
+ )}`,
3540
+ { debugOnly: true },
3541
+ );
3542
+ ackResult = await this.waitForMessageAck(
3543
+ entry.messageId,
3544
+ ackTimeoutMs || PUSH_ACK_TIMEOUT_MS,
3545
+ );
3297
3546
  }
3298
3547
 
3299
3548
  this.logOutboxAckWait({
@@ -3356,14 +3605,8 @@ class BncrBridgeRuntime {
3356
3605
  continue;
3357
3606
  }
3358
3607
 
3359
- entry.routeAttemptConnIds = decision.attemptedConnIds;
3360
- entry.fastReroutePending = decision.fastReroutePending;
3361
- entry.retryCount = decision.nextRetryCount;
3362
- entry.lastAttemptAt = decision.lastAttemptAt;
3363
- entry.nextAttemptAt = decision.nextAttemptAt;
3364
- entry.lastError = decision.lastError;
3365
- entry.routeAttemptRound = decision.routeAttemptRound;
3366
- this.outbox.set(entry.messageId, entry);
3608
+ const nextEntry = applyBncrRetryRerouteDecisionToEntry(entry, decision);
3609
+ this.outbox.set(entry.messageId, nextEntry);
3367
3610
  this.scheduleSave();
3368
3611
  if (requireAck) {
3369
3612
  this.lastAckTimeoutByAccount.set(acc, now());
@@ -3377,7 +3620,7 @@ class BncrBridgeRuntime {
3377
3620
  localNextDelay = updateMinOutboxDelay(localNextDelay, wait);
3378
3621
  this.logOutboxAckReroute({
3379
3622
  accountId: acc,
3380
- entry,
3623
+ entry: nextEntry,
3381
3624
  requireAck,
3382
3625
  currentConnId,
3383
3626
  availableConnIds,
@@ -3408,11 +3651,8 @@ class BncrBridgeRuntime {
3408
3651
  continue;
3409
3652
  }
3410
3653
 
3411
- entry.retryCount = decision.nextRetryCount;
3412
- entry.lastAttemptAt = decision.lastAttemptAt;
3413
- entry.nextAttemptAt = decision.nextAttemptAt;
3414
- entry.lastError = decision.lastError;
3415
- this.outbox.set(entry.messageId, entry);
3654
+ const nextEntry = applyBncrPushFailureDecisionToEntry(entry, decision);
3655
+ this.outbox.set(entry.messageId, nextEntry);
3416
3656
  this.scheduleSave();
3417
3657
 
3418
3658
  const wait = computeOutboxRetryWait(decision.nextAttemptAt, t);
@@ -3452,6 +3692,8 @@ class BncrBridgeRuntime {
3452
3692
  }
3453
3693
  } finally {
3454
3694
  this.pushDrainRunningAccounts.delete(acc);
3695
+ this.pushDrainRunningSinceByAccount.delete(acc);
3696
+ this.pushDrainStuckWarnedAtByAccount.delete(acc);
3455
3697
  }
3456
3698
  }
3457
3699
 
@@ -3474,12 +3716,7 @@ class BncrBridgeRuntime {
3474
3716
 
3475
3717
  private async waitForMessageAck(messageId: string, waitMs: number): Promise<'acked' | 'timeout'> {
3476
3718
  const key = asString(messageId).trim();
3477
- const timeoutMs = clampFiniteNumber(
3478
- waitMs,
3479
- 0,
3480
- 0,
3481
- RECOMMENDED_ACK_TIMEOUT_MAX_MS,
3482
- );
3719
+ const timeoutMs = clampFiniteNumber(waitMs, 0, 0, RECOMMENDED_ACK_TIMEOUT_MAX_MS);
3483
3720
  if (!key || !timeoutMs) return 'timeout';
3484
3721
 
3485
3722
  const existing = this.messageAckWaiters.get(key);
@@ -3581,7 +3818,9 @@ class BncrBridgeRuntime {
3581
3818
  const t = now();
3582
3819
  const prev = this.connections.get(key);
3583
3820
  const previousActiveKey = this.activeConnectionByAccount.get(acc) || null;
3584
- const previousActiveConn = previousActiveKey ? this.connections.get(previousActiveKey) || null : null;
3821
+ const previousActiveConn = previousActiveKey
3822
+ ? this.connections.get(previousActiveKey) || null
3823
+ : null;
3585
3824
 
3586
3825
  const nextConn = {
3587
3826
  accountId: acc,
@@ -3966,9 +4205,7 @@ class BncrBridgeRuntime {
3966
4205
  }
3967
4206
 
3968
4207
  private fileAckKey(transferId: string, stage: string, chunkIndex?: number): string {
3969
- const n = Number(chunkIndex);
3970
- const idx = Number.isInteger(n) && n >= 0 ? String(n) : '-';
3971
- return `${transferId}|${stage}|${idx}`;
4208
+ return buildFileAckKey({ transferId, stage, chunkIndex });
3972
4209
  }
3973
4210
 
3974
4211
  private fileAckOwnerInfo(transferId: string) {
@@ -4003,8 +4240,9 @@ class BncrBridgeRuntime {
4003
4240
  ackStage: stage,
4004
4241
  ackOutcome: cached.ok ? 'acked' : 'failed',
4005
4242
  waiterReused: false,
4006
- chunkIndex:
4007
- Number.isFinite(Number(params.chunkIndex)) ? Number(params.chunkIndex) : undefined,
4243
+ chunkIndex: Number.isFinite(Number(params.chunkIndex))
4244
+ ? Number(params.chunkIndex)
4245
+ : undefined,
4008
4246
  key,
4009
4247
  ...ownerInfo,
4010
4248
  ok: cached.ok,
@@ -4031,8 +4269,9 @@ class BncrBridgeRuntime {
4031
4269
  ackStage: stage,
4032
4270
  ackOutcome: 'waiter-reused',
4033
4271
  waiterReused: true,
4034
- chunkIndex:
4035
- Number.isFinite(Number(params.chunkIndex)) ? Number(params.chunkIndex) : undefined,
4272
+ chunkIndex: Number.isFinite(Number(params.chunkIndex))
4273
+ ? Number(params.chunkIndex)
4274
+ : undefined,
4036
4275
  key,
4037
4276
  ...ownerInfo,
4038
4277
  }),
@@ -4050,8 +4289,9 @@ class BncrBridgeRuntime {
4050
4289
  ackStage: stage,
4051
4290
  ackOutcome: 'waiting',
4052
4291
  waiterReused: false,
4053
- chunkIndex:
4054
- Number.isFinite(Number(params.chunkIndex)) ? Number(params.chunkIndex) : undefined,
4292
+ chunkIndex: Number.isFinite(Number(params.chunkIndex))
4293
+ ? Number(params.chunkIndex)
4294
+ : undefined,
4055
4295
  key,
4056
4296
  ...ownerInfo,
4057
4297
  timeoutMs,
@@ -4076,8 +4316,9 @@ class BncrBridgeRuntime {
4076
4316
  ackStage: stage,
4077
4317
  ackOutcome: 'timeout',
4078
4318
  waiterReused: false,
4079
- chunkIndex:
4080
- Number.isFinite(Number(params.chunkIndex)) ? Number(params.chunkIndex) : undefined,
4319
+ chunkIndex: Number.isFinite(Number(params.chunkIndex))
4320
+ ? Number(params.chunkIndex)
4321
+ : undefined,
4081
4322
  key,
4082
4323
  ...ownerInfo,
4083
4324
  timeoutMs,
@@ -4123,8 +4364,9 @@ class BncrBridgeRuntime {
4123
4364
  ackStage: stage,
4124
4365
  ackOutcome: params.ok ? 'early-acked' : 'early-failed',
4125
4366
  waiterReused: false,
4126
- chunkIndex:
4127
- Number.isFinite(Number(params.chunkIndex)) ? Number(params.chunkIndex) : undefined,
4367
+ chunkIndex: Number.isFinite(Number(params.chunkIndex))
4368
+ ? Number(params.chunkIndex)
4369
+ : undefined,
4128
4370
  key,
4129
4371
  ...ownerInfo,
4130
4372
  ok: params.ok,
@@ -4146,8 +4388,9 @@ class BncrBridgeRuntime {
4146
4388
  ackStage: stage,
4147
4389
  ackOutcome: params.ok ? 'acked' : 'failed',
4148
4390
  waiterReused: false,
4149
- chunkIndex:
4150
- Number.isFinite(Number(params.chunkIndex)) ? Number(params.chunkIndex) : undefined,
4391
+ chunkIndex: Number.isFinite(Number(params.chunkIndex))
4392
+ ? Number(params.chunkIndex)
4393
+ : undefined,
4151
4394
  key,
4152
4395
  ...ownerInfo,
4153
4396
  ok: params.ok,
@@ -4196,31 +4439,6 @@ class BncrBridgeRuntime {
4196
4439
  return mt || 'file';
4197
4440
  }
4198
4441
 
4199
-
4200
- private buildRuntimeQueueSnapshot(accountId: string) {
4201
- const pending = Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length;
4202
- const deadLetter = this.deadLetter.filter((v) => v.accountId === accountId).length;
4203
- const sessionRoutesCount = Array.from(this.sessionRoutes.values()).filter(
4204
- (v) => v.accountId === accountId,
4205
- ).length;
4206
- return {
4207
- pending,
4208
- deadLetter,
4209
- sessionRoutesCount,
4210
- invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(accountId),
4211
- legacyAccountResidue: this.countLegacyAccountResidue(accountId),
4212
- };
4213
- }
4214
-
4215
- private buildRuntimeEventCounters(accountId: string) {
4216
- return {
4217
- connectEvents: this.getCounter(this.connectEventsByAccount, accountId),
4218
- inboundEvents: this.getCounter(this.inboundEventsByAccount, accountId),
4219
- activityEvents: this.getCounter(this.activityEventsByAccount, accountId),
4220
- ackEvents: this.getCounter(this.ackEventsByAccount, accountId),
4221
- };
4222
- }
4223
-
4224
4442
  private computeRecommendedAckTimeoutReason(args: {
4225
4443
  lateAckOkCount: number;
4226
4444
  recentAckTimeoutCount: number;
@@ -4230,26 +4448,15 @@ class BncrBridgeRuntime {
4230
4448
  recommendedAckTimeoutMs?: number;
4231
4449
  nowMs?: number;
4232
4450
  }) {
4233
- if (args.recentAckTimeoutCount <= 0) return 'no-timeout-evidence';
4234
- if (args.lateAckOkCount <= 0) return 'no-late-ack-evidence';
4235
- if (typeof args.lastLateAckPushLatencyMs !== 'number') return 'missing-latency';
4236
- const lastLateAckOkAt = typeof args.lastLateAckOkAt === 'number' ? args.lastLateAckOkAt : null;
4237
- const nowMs = typeof args.nowMs === 'number' ? args.nowMs : now();
4238
- if (
4239
- typeof lastLateAckOkAt === 'number' &&
4240
- lastLateAckOkAt > 0 &&
4241
- nowMs - lastLateAckOkAt > ADAPTIVE_ACK_TIMEOUT_OBSERVATION_TTL_MS
4242
- ) {
4243
- return 'late-ack-expired';
4244
- }
4245
- if (
4246
- typeof args.adaptiveAckRecoveryOkCount === 'number' &&
4247
- args.adaptiveAckRecoveryOkCount >= ADAPTIVE_ACK_TIMEOUT_RECOVERY_OK_THRESHOLD
4248
- ) {
4249
- return 'recovered';
4250
- }
4251
- if (args.recommendedAckTimeoutMs === RECOMMENDED_ACK_TIMEOUT_MAX_MS) return 'capped-max';
4252
- return 'late-ack-observed';
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
+ });
4253
4460
  }
4254
4461
 
4255
4462
  private computeRecommendedAckTimeoutMs(args: {
@@ -4260,29 +4467,15 @@ class BncrBridgeRuntime {
4260
4467
  adaptiveAckRecoveryOkCount?: number;
4261
4468
  nowMs?: number;
4262
4469
  }) {
4263
- const lastLateAckOkAt = typeof args.lastLateAckOkAt === 'number' ? args.lastLateAckOkAt : null;
4264
- const nowMs = typeof args.nowMs === 'number' ? args.nowMs : now();
4265
- const lateAckExpired =
4266
- typeof lastLateAckOkAt === 'number' &&
4267
- lastLateAckOkAt > 0 &&
4268
- nowMs - lastLateAckOkAt > ADAPTIVE_ACK_TIMEOUT_OBSERVATION_TTL_MS;
4269
- const recovered =
4270
- typeof args.adaptiveAckRecoveryOkCount === 'number' &&
4271
- args.adaptiveAckRecoveryOkCount >= ADAPTIVE_ACK_TIMEOUT_RECOVERY_OK_THRESHOLD;
4272
- if (
4273
- args.lateAckOkCount <= 0 ||
4274
- args.recentAckTimeoutCount <= 0 ||
4275
- typeof args.lastLateAckPushLatencyMs !== 'number' ||
4276
- lateAckExpired ||
4277
- recovered
4278
- ) {
4279
- return PUSH_ACK_TIMEOUT_MS;
4280
- }
4281
- const recommended = Math.ceil(args.lastLateAckPushLatencyMs * 1.25);
4282
- return Math.min(
4283
- RECOMMENDED_ACK_TIMEOUT_MAX_MS,
4284
- Math.max(RECOMMENDED_ACK_TIMEOUT_MIN_MS, recommended),
4285
- );
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
+ });
4286
4479
  }
4287
4480
 
4288
4481
  private maybeLogAdaptiveAckTimeout(args: {
@@ -4327,7 +4520,10 @@ class BncrBridgeRuntime {
4327
4520
  const recentAckTimeoutCount = this.getCounter(this.ackTimeoutCountByAccount, acc);
4328
4521
  const lastLateAckPushLatencyMs = this.lastLateAckPushLatencyMsByAccount.get(acc) || null;
4329
4522
  const lastLateAckOkAt = this.lastLateAckOkByAccount.get(acc) || null;
4330
- const adaptiveAckRecoveryOkCount = this.getCounter(this.adaptiveAckRecoveryOkCountByAccount, acc);
4523
+ const adaptiveAckRecoveryOkCount = this.getCounter(
4524
+ this.adaptiveAckRecoveryOkCountByAccount,
4525
+ acc,
4526
+ );
4331
4527
  const nowMs = now();
4332
4528
  const timeoutMs = this.computeRecommendedAckTimeoutMs({
4333
4529
  lateAckOkCount,
@@ -4364,12 +4560,18 @@ class BncrBridgeRuntime {
4364
4560
  const lastLateAckOkAt = this.lastLateAckOkByAccount.get(acc) || null;
4365
4561
  const nowMs = now();
4366
4562
  const lastLateAckAgeMs =
4367
- typeof lastLateAckOkAt === 'number' && lastLateAckOkAt > 0 ? Math.max(0, nowMs - lastLateAckOkAt) : null;
4563
+ typeof lastLateAckOkAt === 'number' && lastLateAckOkAt > 0
4564
+ ? Math.max(0, nowMs - lastLateAckOkAt)
4565
+ : null;
4368
4566
  const lateAckObservationTtlMs = ADAPTIVE_ACK_TIMEOUT_OBSERVATION_TTL_MS;
4369
4567
  const lateAckObservationExpired =
4370
4568
  typeof lastLateAckAgeMs === 'number' && lastLateAckAgeMs > lateAckObservationTtlMs;
4371
- const adaptiveAckRecoveryOkCount = this.getCounter(this.adaptiveAckRecoveryOkCountByAccount, acc);
4372
- const adaptiveAckRecovered = adaptiveAckRecoveryOkCount >= ADAPTIVE_ACK_TIMEOUT_RECOVERY_OK_THRESHOLD;
4569
+ const adaptiveAckRecoveryOkCount = this.getCounter(
4570
+ this.adaptiveAckRecoveryOkCountByAccount,
4571
+ acc,
4572
+ );
4573
+ const adaptiveAckRecovered =
4574
+ adaptiveAckRecoveryOkCount >= ADAPTIVE_ACK_TIMEOUT_RECOVERY_OK_THRESHOLD;
4373
4575
  const recommendedAckTimeoutMs = this.computeRecommendedAckTimeoutMs({
4374
4576
  lateAckOkCount,
4375
4577
  recentAckTimeoutCount,
@@ -4412,44 +4614,42 @@ class BncrBridgeRuntime {
4412
4614
  }
4413
4615
 
4414
4616
  private buildRuntimeAckStrategy(ackObservability: Record<string, any>) {
4415
- const currentMs = finiteNumberOr(ackObservability.currentAckTimeoutMs, PUSH_ACK_TIMEOUT_MS);
4416
- const defaultMs = finiteNumberOr(ackObservability.defaultAckTimeoutMs, PUSH_ACK_TIMEOUT_MS);
4417
- const reason = asString(ackObservability.recommendedAckTimeoutReason || 'unknown') || 'unknown';
4418
- return {
4419
- mode: ackObservability.adaptiveAckTimeoutEnabled === true ? 'adaptive' : 'fixed',
4420
- currentMs,
4421
- defaultMs,
4422
- maxMs: RECOMMENDED_ACK_TIMEOUT_MAX_MS,
4423
- reason,
4424
- active: currentMs > defaultMs,
4425
- lastLateAckAgeMs: ackObservability.lastLateAckAgeMs ?? null,
4426
- lateAckObservationTtlMs: ackObservability.lateAckObservationTtlMs ?? null,
4427
- recovered: ackObservability.adaptiveAckRecovered === true,
4428
- };
4429
- }
4430
-
4431
- private buildRuntimeActivitySnapshot(accountId: string) {
4432
- return {
4433
- activeConnections: this.activeConnectionCount(accountId),
4434
- lastSession: this.lastSessionByAccount.get(accountId) || null,
4435
- lastActivityAt: this.lastActivityByAccount.get(accountId) || null,
4436
- lastInboundAt: this.lastInboundByAccount.get(accountId) || null,
4437
- lastOutboundAt: this.lastOutboundByAccount.get(accountId) || null,
4438
- };
4617
+ return buildBncrRuntimeAckStrategy({
4618
+ ackObservability,
4619
+ defaultAckTimeoutMs: PUSH_ACK_TIMEOUT_MS,
4620
+ maxAckTimeoutMs: RECOMMENDED_ACK_TIMEOUT_MAX_MS,
4621
+ });
4439
4622
  }
4440
4623
 
4441
4624
  private buildRuntimeStatusInput(accountId: string, overrides: { running?: boolean } = {}) {
4442
4625
  const acc = normalizeAccountId(accountId);
4443
- return {
4626
+ const snapshots = buildRuntimeStatusSnapshots({
4627
+ accountId: acc,
4628
+ outboxEntries: this.outbox.values(),
4629
+ deadLetterEntries: this.deadLetter,
4630
+ sessionRouteEntries: this.sessionRoutes.values(),
4631
+ countInvalidOutboxSessionKeys: (snapshotAccountId) =>
4632
+ this.countInvalidOutboxSessionKeys(snapshotAccountId),
4633
+ countLegacyAccountResidue: (snapshotAccountId) =>
4634
+ this.countLegacyAccountResidue(snapshotAccountId),
4635
+ connectEventsByAccount: this.connectEventsByAccount,
4636
+ inboundEventsByAccount: this.inboundEventsByAccount,
4637
+ activityEventsByAccount: this.activityEventsByAccount,
4638
+ ackEventsByAccount: this.ackEventsByAccount,
4639
+ activeConnectionCount: (snapshotAccountId) => this.activeConnectionCount(snapshotAccountId),
4640
+ lastSessionByAccount: this.lastSessionByAccount,
4641
+ lastActivityByAccount: this.lastActivityByAccount,
4642
+ lastInboundByAccount: this.lastInboundByAccount,
4643
+ lastOutboundByAccount: this.lastOutboundByAccount,
4644
+ });
4645
+ return buildBncrRuntimeStatusInput({
4444
4646
  accountId: acc,
4445
4647
  connected: this.isOnline(acc),
4446
- ...this.buildRuntimeQueueSnapshot(acc),
4447
- ...this.buildRuntimeEventCounters(acc),
4448
- ...this.buildRuntimeActivitySnapshot(acc),
4648
+ ...snapshots,
4449
4649
  startedAt: this.startedAt,
4450
4650
  running: overrides.running,
4451
4651
  channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
4452
- };
4652
+ });
4453
4653
  }
4454
4654
 
4455
4655
  private buildStatusMeta(accountId: string) {
@@ -4457,7 +4657,9 @@ class BncrBridgeRuntime {
4457
4657
  }
4458
4658
 
4459
4659
  getAccountRuntimeSnapshot(accountId: string) {
4460
- const snapshot = buildAccountRuntimeSnapshot(this.buildRuntimeStatusInput(accountId, { running: true }));
4660
+ const snapshot = buildAccountRuntimeSnapshot(
4661
+ this.buildRuntimeStatusInput(accountId, { running: true }),
4662
+ );
4461
4663
  const ackObservability = this.buildRuntimeAckObservability(accountId);
4462
4664
  const ackStrategy = this.buildRuntimeAckStrategy(ackObservability);
4463
4665
  return {
@@ -4531,7 +4733,7 @@ class BncrBridgeRuntime {
4531
4733
  this.logOutboundSummary(entry);
4532
4734
  this.outbox.set(entry.messageId, entry);
4533
4735
  this.scheduleSave();
4534
- this.flushPushQueue(entry.accountId);
4736
+ this.flushPushQueueBestEffort({ accountId: entry.accountId });
4535
4737
  }
4536
4738
 
4537
4739
  private moveToDeadLetter(entry: OutboxEntry, reason: string) {
@@ -4825,34 +5027,6 @@ class BncrBridgeRuntime {
4825
5027
  );
4826
5028
  }
4827
5029
 
4828
- private buildFileTransferInitPayload(args: {
4829
- transferId: string;
4830
- sessionKey: string;
4831
- route: BncrRoute;
4832
- fileName: string;
4833
- mimeType?: string;
4834
- fileSize: number;
4835
- chunkSize: number;
4836
- totalChunks: number;
4837
- fileSha256: string;
4838
- }) {
4839
- return {
4840
- transferId: args.transferId,
4841
- direction: 'oc2bncr' as const,
4842
- sessionKey: args.sessionKey,
4843
- platform: args.route.platform,
4844
- groupId: args.route.groupId,
4845
- userId: args.route.userId,
4846
- fileName: args.fileName,
4847
- mimeType: args.mimeType,
4848
- fileSize: args.fileSize,
4849
- chunkSize: args.chunkSize,
4850
- totalChunks: args.totalChunks,
4851
- fileSha256: args.fileSha256,
4852
- ts: now(),
4853
- };
4854
- }
4855
-
4856
5030
  private buildInitialFileSendTransferState(args: {
4857
5031
  transferId: string;
4858
5032
  accountId: string;
@@ -5043,7 +5217,7 @@ class BncrBridgeRuntime {
5043
5217
 
5044
5218
  ctx.broadcastToConnIds(
5045
5219
  BNCR_FILE_INIT_EVENT,
5046
- this.buildFileTransferInitPayload({
5220
+ buildFileTransferInitPayload({
5047
5221
  transferId,
5048
5222
  sessionKey: params.sessionKey,
5049
5223
  route: params.route,
@@ -5053,6 +5227,7 @@ class BncrBridgeRuntime {
5053
5227
  chunkSize,
5054
5228
  totalChunks,
5055
5229
  fileSha256,
5230
+ ts: now(),
5056
5231
  }),
5057
5232
  connIds,
5058
5233
  );
@@ -5069,7 +5244,7 @@ class BncrBridgeRuntime {
5069
5244
  for (let attempt = 1; attempt <= 3; attempt++) {
5070
5245
  ctx.broadcastToConnIds(
5071
5246
  BNCR_FILE_CHUNK_EVENT,
5072
- {
5247
+ buildFileTransferChunkPayload({
5073
5248
  transferId,
5074
5249
  chunkIndex: idx,
5075
5250
  offset: start,
@@ -5077,7 +5252,7 @@ class BncrBridgeRuntime {
5077
5252
  chunkSha256,
5078
5253
  base64: slice.toString('base64'),
5079
5254
  ts: now(),
5080
- },
5255
+ }),
5081
5256
  connIds,
5082
5257
  );
5083
5258
 
@@ -5125,11 +5300,11 @@ class BncrBridgeRuntime {
5125
5300
  this.fileSendTransfers.set(transferId, st);
5126
5301
  ctx.broadcastToConnIds(
5127
5302
  BNCR_FILE_ABORT_EVENT,
5128
- {
5303
+ buildFileTransferAbortPayload({
5129
5304
  transferId,
5130
5305
  reason: st.error,
5131
5306
  ts: now(),
5132
- },
5307
+ }),
5133
5308
  connIds,
5134
5309
  );
5135
5310
  throw new Error(st.error);
@@ -5138,10 +5313,10 @@ class BncrBridgeRuntime {
5138
5313
 
5139
5314
  ctx.broadcastToConnIds(
5140
5315
  BNCR_FILE_COMPLETE_EVENT,
5141
- {
5316
+ buildFileTransferCompletePayload({
5142
5317
  transferId,
5143
5318
  ts: now(),
5144
- },
5319
+ }),
5145
5320
  connIds,
5146
5321
  );
5147
5322
 
@@ -5330,7 +5505,7 @@ class BncrBridgeRuntime {
5330
5505
  });
5331
5506
 
5332
5507
  // WS 一旦在线,立即尝试把离线期间积压队列直推出去
5333
- this.flushPushQueue({
5508
+ this.flushPushQueueBestEffort({
5334
5509
  accountId,
5335
5510
  trigger: OUTBOUND_FLUSH_TRIGGER.CONNECT,
5336
5511
  reason: OUTBOUND_FLUSH_REASON.WS_ONLINE,
@@ -5412,7 +5587,7 @@ class BncrBridgeRuntime {
5412
5587
  deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
5413
5588
  now: now(),
5414
5589
  });
5415
- this.flushPushQueue({
5590
+ this.flushPushQueueBestEffort({
5416
5591
  accountId,
5417
5592
  trigger: OUTBOUND_FLUSH_TRIGGER.ACTIVITY,
5418
5593
  reason: OUTBOUND_FLUSH_REASON.ACTIVITY_HEARTBEAT,
@@ -5575,8 +5750,21 @@ class BncrBridgeRuntime {
5575
5750
  respond(false, { error: 'transfer not found' });
5576
5751
  return;
5577
5752
  }
5753
+ if (st.status === 'completed') {
5754
+ respond(true, {
5755
+ ok: true,
5756
+ transferId,
5757
+ status: 'completed',
5758
+ path: st.completedPath,
5759
+ ignored: true,
5760
+ terminal: true,
5761
+ });
5762
+ return;
5763
+ }
5578
5764
  if (chunkIndex >= st.totalChunks) {
5579
- respond(false, { error: `chunkIndex out of range index=${chunkIndex} total=${st.totalChunks}` });
5765
+ respond(false, {
5766
+ error: `chunkIndex out of range index=${chunkIndex} total=${st.totalChunks}`,
5767
+ });
5580
5768
  return;
5581
5769
  }
5582
5770
 
@@ -5778,6 +5966,17 @@ class BncrBridgeRuntime {
5778
5966
  respond(true, { ok: true, transferId, message: 'not-found' });
5779
5967
  return;
5780
5968
  }
5969
+ if (st.status === 'completed') {
5970
+ respond(true, {
5971
+ ok: true,
5972
+ transferId,
5973
+ status: 'completed',
5974
+ path: st.completedPath,
5975
+ ignored: true,
5976
+ terminal: true,
5977
+ });
5978
+ return;
5979
+ }
5781
5980
 
5782
5981
  const staleObserved = this.observeLease('file.abort', params ?? {});
5783
5982
  if (staleObserved.stale) {
@@ -5874,6 +6073,30 @@ class BncrBridgeRuntime {
5874
6073
  ? 'file.abort'
5875
6074
  : 'file.complete';
5876
6075
  const staleObserved = this.observeLease(staleKind, params ?? {});
6076
+ if (st?.status === 'completed' || st?.status === 'aborted') {
6077
+ respond(
6078
+ true,
6079
+ staleObserved.stale
6080
+ ? {
6081
+ ok: true,
6082
+ transferId,
6083
+ stage,
6084
+ state: st.status,
6085
+ stale: true,
6086
+ ignored: true,
6087
+ terminal: true,
6088
+ }
6089
+ : {
6090
+ ok: true,
6091
+ transferId,
6092
+ stage,
6093
+ state: st.status,
6094
+ ignored: true,
6095
+ terminal: true,
6096
+ },
6097
+ );
6098
+ return;
6099
+ }
5877
6100
  if (staleObserved.stale) {
5878
6101
  const sameConn = !!st?.ownerConnId && st.ownerConnId === connId;
5879
6102
  const sameClient =
@@ -6100,7 +6323,7 @@ class BncrBridgeRuntime {
6100
6323
  taskKey: extracted.taskKey ?? null,
6101
6324
  }),
6102
6325
  );
6103
- this.flushPushQueue({
6326
+ this.flushPushQueueBestEffort({
6104
6327
  accountId,
6105
6328
  trigger: OUTBOUND_FLUSH_TRIGGER.INBOUND,
6106
6329
  reason: OUTBOUND_FLUSH_REASON.INBOUND_ACCEPTED,
@@ -6130,126 +6353,11 @@ class BncrBridgeRuntime {
6130
6353
  };
6131
6354
 
6132
6355
  channelStartAccount = async (ctx: any) => {
6133
- const accountId = normalizeAccountId(ctx.accountId);
6134
- this.clearChannelAccountWorker(accountId, 'start-replace');
6135
-
6136
- const tick = () => {
6137
- const previous = ctx.getStatus?.() || {};
6138
- const onlineByConn = this.isOnline(accountId);
6139
- const recentInboundReachable = this.hasRecentInboundReachability(accountId);
6140
- const connected = onlineByConn || recentInboundReachable;
6141
- const lastActAt =
6142
- this.lastActivityByAccount.get(accountId) ||
6143
- this.lastInboundByAccount.get(accountId) ||
6144
- this.lastOutboundByAccount.get(accountId) ||
6145
- previous?.lastEventAt ||
6146
- null;
6147
- const healthSig = JSON.stringify({
6148
- bridge: this.bridgeId,
6149
- accountId,
6150
- connected,
6151
- onlineByConn,
6152
- recentInboundReachable,
6153
- activeConnectionKey: this.activeConnectionByAccount.get(accountId) || null,
6154
- activeConnections: Array.from(this.connections.values())
6155
- .filter((c) => c.accountId === accountId)
6156
- .map((c) => ({
6157
- connId: c.connId,
6158
- clientId: c.clientId,
6159
- inboundOnly: c.inboundOnly === true,
6160
- outboundReady: c.outboundReady === true,
6161
- preferredForOutbound: c.preferredForOutbound === true,
6162
- })),
6163
- });
6164
- const conns = Array.from(this.connections.values()).filter((c) => c.accountId === accountId).length;
6165
- this.logInfoDedup(
6166
- 'health',
6167
- `status-tick ${accountId}|changed|${connected ? 'linked' : 'configured'}|onlineByConn=${onlineByConn}|recentInboundReachable=${recentInboundReachable}|conns=${conns}`,
6168
- {
6169
- key: `health-status-tick:${accountId}`,
6170
- sig: healthSig,
6171
- },
6172
- );
6173
- this.logInfoDedup('health', `status-tick ${healthSig}`, {
6174
- key: `health-status-tick-debug:${accountId}`,
6175
- sig: healthSig,
6176
- debugOnly: true,
6177
- });
6178
-
6179
- ctx.setStatus?.({
6180
- ...previous,
6181
- accountId,
6182
- running: true,
6183
- connected,
6184
- lastEventAt: lastActAt,
6185
- // 状态映射:在线=linked,离线=configured
6186
- mode: connected ? 'linked' : 'configured',
6187
- lastError: previous?.lastError ?? null,
6188
- meta: this.buildStatusMeta(accountId),
6189
- });
6190
- };
6191
-
6192
- tick();
6193
- const timer = setInterval(tick, 5_000);
6194
- let worker!: ChannelAccountWorkerHandle;
6195
- const done = new Promise<void>((resolve) => {
6196
- let settled = false;
6197
- const finish = (reason: string) => {
6198
- if (settled) return;
6199
- settled = true;
6200
- const activeWorker = this.channelAccountWorkers.get(accountId);
6201
- if (activeWorker === worker) {
6202
- this.channelAccountWorkers.delete(accountId);
6203
- }
6204
- clearInterval(timer);
6205
- worker.cleanupAbortListener?.();
6206
- worker.cleanupAbortListener = undefined;
6207
- this.logInfo(
6208
- 'health',
6209
- `status-worker finished ${JSON.stringify({ bridge: this.bridgeId, accountId, reason })}`,
6210
- { debugOnly: true },
6211
- );
6212
- this.logInfo('health', `status-worker finished ${accountId}|${reason}`);
6213
- resolve();
6214
- };
6215
-
6216
- worker = { timer, finish };
6217
- this.channelAccountWorkers.set(accountId, worker);
6218
-
6219
- const onAbort = () => finish('abort');
6220
- const abortSignal = ctx.abortSignal;
6221
-
6222
- if (abortSignal?.aborted) {
6223
- onAbort();
6224
- return;
6225
- }
6226
-
6227
- abortSignal?.addEventListener?.('abort', onAbort, { once: true });
6228
- if (abortSignal?.removeEventListener) {
6229
- worker.cleanupAbortListener = () => abortSignal.removeEventListener('abort', onAbort);
6230
- }
6231
- });
6232
- await done;
6356
+ await startBncrStatusWorker(this.buildStatusWorkerRuntime(), ctx);
6233
6357
  };
6234
6358
 
6235
6359
  channelStopAccount = async (ctx: any) => {
6236
- const accountId = normalizeAccountId(ctx?.accountId);
6237
- const cleared = this.clearChannelAccountWorker(accountId, 'explicit-stop');
6238
- const previous = ctx?.getStatus?.() || {};
6239
- ctx?.setStatus?.({
6240
- ...previous,
6241
- accountId,
6242
- running: false,
6243
- restartPending: false,
6244
- lastStopAt: Date.now(),
6245
- meta: this.buildStatusMeta(accountId),
6246
- });
6247
- this.logInfo(
6248
- 'health',
6249
- `status-stop ${JSON.stringify({ bridge: this.bridgeId, accountId, cleared })}`,
6250
- { debugOnly: true },
6251
- );
6252
- this.logInfo('health', `status-stop ${accountId}|cleared=${cleared}`);
6360
+ await stopBncrStatusWorker(this.buildStatusWorkerRuntime(), ctx);
6253
6361
  };
6254
6362
 
6255
6363
  private logChannelSendEntry(args: {
@@ -6380,7 +6488,9 @@ class BncrBridgeRuntime {
6380
6488
  payload,
6381
6489
  mediaLocalRoots: ctx.mediaLocalRoots,
6382
6490
  });
6383
- const entries = Array.from(this.outbox.values()).filter((entry) => !before.has(entry.messageId));
6491
+ const entries = Array.from(this.outbox.values()).filter(
6492
+ (entry) => !before.has(entry.messageId),
6493
+ );
6384
6494
  if (!entries.length) {
6385
6495
  throw new Error('bncr channel.message handoff did not enqueue an outbox entry');
6386
6496
  }
@@ -6421,7 +6531,9 @@ class BncrBridgeRuntime {
6421
6531
  asVoice: payload.asVoice === true,
6422
6532
  audioAsVoice: payload.audioAsVoice === true,
6423
6533
  kind: payload.kind,
6424
- replyToId: asString(payload.replyToId || ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined,
6534
+ replyToId:
6535
+ asString(payload.replyToId || ctx?.replyToId || ctx?.replyToMessageId || '').trim() ||
6536
+ undefined,
6425
6537
  });
6426
6538
  return buildBncrDurableQueuedResult({ entry });
6427
6539
  };
@@ -6460,7 +6572,7 @@ export function createBncrChannelPlugin(getBridge: () => BncrBridgeRuntime) {
6460
6572
  };
6461
6573
  },
6462
6574
  supportsAction: ({ action }) => action === 'send',
6463
- extractToolSend: ({ args }) => extractOpenClawToolSend(args, 'sendMessage'),
6575
+ extractToolSend: ({ args }) => extractOpenClawToolSend(args, 'sendMessage'),
6464
6576
  handleAction: async ({ action, params, accountId, mediaLocalRoots }) => {
6465
6577
  if (action !== 'send')
6466
6578
  throw new Error(`Action ${action} is not supported for provider ${CHANNEL_ID}.`);
@@ -6504,231 +6616,21 @@ export function createBncrChannelPlugin(getBridge: () => BncrBridgeRuntime) {
6504
6616
 
6505
6617
  const plugin = {
6506
6618
  id: CHANNEL_ID,
6507
- meta: {
6508
- id: CHANNEL_ID,
6509
- label: 'Bncr',
6510
- selectionLabel: 'Bncr Client',
6511
- docsPath: '/channels/bncr',
6512
- blurb: 'Bncr Channel.',
6513
- aliases: ['bncr'],
6514
- },
6619
+ meta: BNCR_CHANNEL_META,
6515
6620
  actions: messageActions,
6516
6621
  message: {
6517
- receive: {
6518
- defaultAckPolicy: 'manual' as const,
6519
- supportedAckPolicies: ['manual'] as const,
6520
- },
6521
- send: {
6522
- text: async (ctx: any) => getBridge().channelMessageSendText(ctx),
6523
- media: async (ctx: any) => getBridge().channelMessageSendMedia(ctx),
6524
- payload: async (ctx: any) => getBridge().channelMessageSendPayload(ctx),
6525
- },
6526
- },
6527
- capabilities: {
6528
- chatTypes: ['direct'] as ChatType[],
6529
- media: true,
6530
- reply: true,
6531
- nativeCommands: true,
6532
- },
6533
- messaging: {
6534
- // 接收任意标签输入;不在 normalize 阶段做格式门槛,统一下沉到发送前验证。
6535
- normalizeTarget: (raw: string) => {
6536
- const input = asString(raw).trim();
6537
- return input || undefined;
6538
- },
6539
- parseExplicitTarget: ({ raw, accountId, cfg }: any) => {
6540
- const resolvedAccountId = normalizeAccountId(
6541
- asString(accountId || BNCR_DEFAULT_ACCOUNT_ID),
6542
- );
6543
- const runtimeBridge = getBridge();
6544
- const canonicalAgentId =
6545
- runtimeBridge.canonicalAgentId ||
6546
- runtimeBridge.ensureCanonicalAgentId({ cfg, accountId: resolvedAccountId });
6547
- return parseExplicitTarget(asString(raw).trim(), { canonicalAgentId });
6548
- },
6549
- formatTargetDisplay: ({ target }: any) => {
6550
- return formatTargetDisplay(target);
6551
- },
6552
- resolveSessionTarget: ({ id, accountId, cfg }: any) => {
6553
- const raw = asString(id).trim();
6554
- if (!raw) return undefined;
6555
- const resolvedAccountId = normalizeAccountId(
6556
- asString(accountId || BNCR_DEFAULT_ACCOUNT_ID),
6557
- );
6558
- const runtimeBridge = getBridge();
6559
- const canonicalAgentId =
6560
- runtimeBridge.canonicalAgentId ||
6561
- runtimeBridge.ensureCanonicalAgentId({ cfg, accountId: resolvedAccountId });
6562
-
6563
- let parsed = parseExplicitTarget(raw, { canonicalAgentId });
6564
- if (!parsed) {
6565
- const route = runtimeBridge.resolveRouteBySession(raw, resolvedAccountId);
6566
- if (route) {
6567
- parsed = parseExplicitTarget(formatDisplayScope(route), { canonicalAgentId });
6568
- }
6569
- }
6570
- return parsed?.displayScope || undefined;
6571
- },
6572
- resolveOutboundSessionRoute: (params: any) => {
6573
- const accountId = normalizeAccountId(
6574
- asString(params?.accountId || BNCR_DEFAULT_ACCOUNT_ID),
6575
- );
6576
- const runtimeBridge = getBridge();
6577
- const canonicalAgentId =
6578
- runtimeBridge.canonicalAgentId ||
6579
- runtimeBridge.ensureCanonicalAgentId({ cfg: params?.cfg, accountId });
6580
- return resolveBncrOutboundSessionRoute({
6581
- ...params,
6582
- canonicalAgentId,
6583
- resolveRouteBySession: (raw: string, acc: string) =>
6584
- runtimeBridge.resolveRouteBySession(raw, acc),
6585
- });
6586
- },
6587
- targetResolver: {
6588
- looksLikeId: (raw: string, normalized?: string) => {
6589
- return looksLikeBncrExplicitTarget(asString(normalized || raw).trim());
6590
- },
6591
- resolveTarget: async ({ accountId, input, normalized }) => {
6592
- const runtimeBridge = getBridge();
6593
- const resolved = resolveBncrOutboundTarget({
6594
- target: asString(normalized || input).trim(),
6595
- accountId: normalizeAccountId(asString(accountId || BNCR_DEFAULT_ACCOUNT_ID)),
6596
- resolveRouteBySession: (raw: string, acc: string) =>
6597
- runtimeBridge.resolveRouteBySession(raw, acc),
6598
- });
6599
- if (!resolved) return null;
6600
- return {
6601
- to: resolved.displayScope,
6602
- kind: resolved.kind,
6603
- display: resolved.displayScope,
6604
- source: 'normalized' as const,
6605
- };
6606
- },
6607
- hint: 'Standard to=Bncr:<platform>:<group>:<user> or Bncr:<platform>:<user>; sessionKey keeps existing strict/legacy compatibility, canonical sessionKey=agent:<agentId>:bncr:direct:<hex>',
6608
- },
6622
+ receive: BNCR_MESSAGE_RECEIVE_POLICY,
6623
+ send: createBncrMessageSend(getBridge),
6609
6624
  },
6625
+ capabilities: BNCR_CHANNEL_CAPABILITIES,
6626
+ messaging: createBncrMessagingSurface(getBridge),
6610
6627
  configSchema: BncrConfigSchema,
6611
- config: {
6612
- listAccountIds,
6613
- resolveAccount,
6614
- setAccountEnabled: ({ cfg, accountId, enabled }: any) =>
6615
- setOpenClawAccountEnabledInConfigSection({
6616
- cfg,
6617
- sectionKey: CHANNEL_ID,
6618
- accountId,
6619
- enabled,
6620
- allowTopLevel: true,
6621
- }),
6622
- isEnabled: (account: any, cfg: any) => {
6623
- const policy = resolveBncrChannelPolicy(cfg?.channels?.[CHANNEL_ID] || {});
6624
- return policy.enabled !== false && account?.enabled !== false;
6625
- },
6626
- isConfigured: () => true,
6627
- describeAccount: (account: any) => {
6628
- const displayName = resolveDefaultDisplayName(account?.name, account?.accountId);
6629
- return {
6630
- accountId: account.accountId,
6631
- name: displayName,
6632
- enabled: account.enabled !== false,
6633
- configured: true,
6634
- };
6635
- },
6636
- },
6637
- setup: {
6638
- applyAccountName: ({ cfg, accountId, name }: any) =>
6639
- applyOpenClawAccountNameToChannelSection({
6640
- cfg,
6641
- channelKey: CHANNEL_ID,
6642
- accountId,
6643
- name,
6644
- alwaysUseAccounts: true,
6645
- }),
6646
- applyAccountConfig: ({ cfg, accountId }: any) => {
6647
- const next = { ...(cfg || {}) } as any;
6648
- next.channels = next.channels || {};
6649
- next.channels[CHANNEL_ID] = next.channels[CHANNEL_ID] || {};
6650
- next.channels[CHANNEL_ID].accounts = next.channels[CHANNEL_ID].accounts || {};
6651
- next.channels[CHANNEL_ID].accounts[accountId] = {
6652
- ...(next.channels[CHANNEL_ID].accounts[accountId] || {}),
6653
- enabled: true,
6654
- };
6655
- return next;
6656
- },
6657
- },
6658
- outbound: {
6659
- deliveryMode: 'gateway' as const,
6660
- sendText: async (ctx: any) => getBridge().channelSendText(ctx),
6661
- sendMedia: async (ctx: any) => getBridge().channelSendMedia(ctx),
6662
- replyAction: async (ctx: any) =>
6663
- sendBncrReplyAction({
6664
- accountId: normalizeAccountId(ctx?.accountId),
6665
- to: asString(ctx?.to || '').trim(),
6666
- text: asString(ctx?.text || ''),
6667
- replyToMessageId:
6668
- asString(ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined,
6669
- sendText: async ({ accountId, to, text }) =>
6670
- getBridge().channelSendText({ accountId, to, text }),
6671
- }),
6672
- deleteAction: async (ctx: any) =>
6673
- deleteBncrMessageAction({
6674
- accountId: normalizeAccountId(ctx?.accountId),
6675
- targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
6676
- }),
6677
- reactAction: async (ctx: any) =>
6678
- reactBncrMessageAction({
6679
- accountId: normalizeAccountId(ctx?.accountId),
6680
- targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
6681
- emoji: asString(ctx?.emoji || '').trim(),
6682
- }),
6683
- editAction: async (ctx: any) =>
6684
- editBncrMessageAction({
6685
- accountId: normalizeAccountId(ctx?.accountId),
6686
- targetMessageId: asString(ctx?.messageId || ctx?.targetMessageId || '').trim(),
6687
- text: asString(ctx?.text || ''),
6688
- }),
6689
- },
6690
- status: {
6691
- defaultRuntime: createOpenClawDefaultChannelRuntimeState(BNCR_DEFAULT_ACCOUNT_ID, {
6692
- mode: 'ws-offline',
6693
- }),
6694
- buildChannelSummary: async ({ defaultAccountId }: any) => {
6695
- return getBridge().getChannelSummary(defaultAccountId || BNCR_DEFAULT_ACCOUNT_ID);
6696
- },
6697
- buildAccountSnapshot: async ({ account, runtime }: any) => {
6698
- const runtimeBridge = getBridge();
6699
- const rt = runtime || runtimeBridge.getAccountRuntimeSnapshot(account?.accountId);
6700
- return buildAccountStatusSnapshot({
6701
- account,
6702
- runtime: rt,
6703
- healthSummary: runtimeBridge.getStatusHeadline(account?.accountId),
6704
- // default 名不可隐藏时,统一展示稳定默认值
6705
- displayName: resolveDefaultDisplayName(account?.name, account?.accountId),
6706
- });
6707
- },
6708
- resolveAccountState: ({ enabled, configured, account, cfg, runtime }: any) => {
6709
- if (!enabled) return 'disabled';
6710
- const resolved = resolveAccount(cfg, account?.accountId);
6711
- if (!(resolved.enabled && configured)) return 'not configured';
6712
- const rt = runtime || getBridge().getAccountRuntimeSnapshot(account?.accountId);
6713
- return rt?.connected ? 'linked' : 'configured';
6714
- },
6715
- },
6716
- gatewayMethods: [
6717
- 'bncr.connect',
6718
- 'bncr.inbound',
6719
- 'bncr.activity',
6720
- 'bncr.ack',
6721
- 'bncr.diagnostics',
6722
- 'bncr.file.init',
6723
- 'bncr.file.chunk',
6724
- 'bncr.file.complete',
6725
- 'bncr.file.abort',
6726
- 'bncr.file.ack',
6727
- ],
6728
- gateway: {
6729
- startAccount: async (ctx: any) => getBridge().channelStartAccount(ctx),
6730
- stopAccount: async (ctx: any) => getBridge().channelStopAccount(ctx),
6731
- },
6628
+ config: BNCR_CONFIG_SURFACE,
6629
+ setup: BNCR_SETUP_SURFACE,
6630
+ outbound: createBncrOutboundRuntime(getBridge),
6631
+ status: createBncrStatusSurface(getBridge),
6632
+ gatewayMethods: BNCR_GATEWAY_METHODS,
6633
+ gateway: createBncrGatewayRuntime(getBridge),
6732
6634
  };
6733
6635
 
6734
6636
  return plugin;