@xmoxmo/bncr 0.2.3 → 0.2.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 (36) hide show
  1. package/README.md +67 -4
  2. package/index.ts +24 -1
  3. package/package.json +1 -1
  4. package/src/channel.ts +2823 -1178
  5. package/src/core/connection-capability.ts +70 -0
  6. package/src/core/connection-reachability.ts +141 -0
  7. package/src/core/diagnostics.ts +49 -0
  8. package/src/core/downlink-health.ts +56 -0
  9. package/src/core/extended-diagnostics.ts +65 -0
  10. package/src/core/lease-state.ts +94 -0
  11. package/src/core/outbox-enqueue.ts +22 -0
  12. package/src/core/outbox-entry-builders.ts +91 -0
  13. package/src/core/outbox-file-transfer-bookkeeping.ts +31 -0
  14. package/src/core/outbox-file-transfer-failure.ts +25 -0
  15. package/src/core/outbox-file-transfer-guards.ts +66 -0
  16. package/src/core/outbox-file-transfer-prep.ts +31 -0
  17. package/src/core/outbox-file-transfer-success.ts +34 -0
  18. package/src/core/outbox-push-args.ts +67 -0
  19. package/src/core/outbox-queue.ts +69 -0
  20. package/src/core/outbox-summary.ts +14 -0
  21. package/src/core/outbox-text-push-failure.ts +10 -0
  22. package/src/core/outbox-text-push-guards.ts +51 -0
  23. package/src/core/outbox-text-push-prep.ts +36 -0
  24. package/src/core/outbox-text-push-success.ts +62 -0
  25. package/src/core/register-trace.ts +110 -0
  26. package/src/core/status.ts +62 -10
  27. package/src/core/types.ts +3 -0
  28. package/src/messaging/inbound/dispatch.ts +86 -48
  29. package/src/messaging/outbound/diagnostics.ts +246 -0
  30. package/src/messaging/outbound/media-dedupe.ts +51 -0
  31. package/src/messaging/outbound/queue-selectors.ts +186 -0
  32. package/src/messaging/outbound/reasons.ts +48 -0
  33. package/src/messaging/outbound/reply-enqueue.ts +329 -0
  34. package/src/messaging/outbound/retry-policy.ts +133 -0
  35. package/src/messaging/outbound/send.ts +2 -0
  36. package/src/messaging/outbound/session-route.ts +34 -5
package/src/channel.ts CHANGED
@@ -25,12 +25,66 @@ import {
25
25
  resolveDefaultDisplayName,
26
26
  } from './core/accounts.ts';
27
27
  import { BncrConfigSchema } from './core/config-schema.ts';
28
+ import {
29
+ applyOutboundCapability,
30
+ buildCapabilitySnapshot,
31
+ clearOutboundCapability,
32
+ findCapabilityConnection,
33
+ } from './core/connection-capability.ts';
34
+ import {
35
+ buildFileTransferOutboxEntry as buildFileTransferOutboxEntryFromRuntime,
36
+ buildTextOutboxEntry as buildTextOutboxEntryFromRuntime,
37
+ } from './core/outbox-entry-builders.ts';
38
+ import { buildOutboxEnqueueDebugInfo } from './core/outbox-enqueue.ts';
39
+ import {
40
+ appendDeadLetter,
41
+ buildDeadLetterEntry,
42
+ collectDueOutboxEntries,
43
+ } from './core/outbox-queue.ts';
44
+ import { resolveFileTransferGuard } from './core/outbox-file-transfer-guards.ts';
45
+ import { prepareFileTransferRouteSelection } from './core/outbox-file-transfer-prep.ts';
46
+ import {
47
+ buildFileTransferPushOkArgs,
48
+ buildFileTransferPushSuccessArgs,
49
+ } from './core/outbox-file-transfer-bookkeeping.ts';
50
+ import {
51
+ buildFileTransferPushFailureArgs,
52
+ resolveFileTransferFailureState,
53
+ } from './core/outbox-file-transfer-failure.ts';
54
+ import {
55
+ buildFileTransferBroadcastPayload,
56
+ buildFileTransferRouteSelectArgs,
57
+ } from './core/outbox-file-transfer-success.ts';
58
+ import { summarizeOutboxEntry } from './core/outbox-summary.ts';
59
+ import { resolveTextPushGuard } from './core/outbox-text-push-guards.ts';
60
+ import { prepareTextPushRouteSelection } from './core/outbox-text-push-prep.ts';
61
+ import { buildTextPushFailureArgs } from './core/outbox-text-push-failure.ts';
62
+ import {
63
+ buildTextPushBroadcastPayload,
64
+ buildTextPushOkArgs,
65
+ buildTextPushRouteSelectArgs,
66
+ buildTextPushSuccessArgs,
67
+ } from './core/outbox-text-push-success.ts';
68
+ import {
69
+ getRevalidatedAttemptReason,
70
+ hasAlternativeLiveConnection as hasAlternativeLiveConnectionFromRuntime,
71
+ hasRecentInboundReachability as hasRecentInboundReachabilityFromRuntime,
72
+ isRecentlyReachableConn as isRecentlyReachableConnFromRuntime,
73
+ resolveRecentInboundConnIds as resolveRecentInboundConnIdsFromRuntime,
74
+ } from './core/connection-reachability.ts';
75
+ import { buildDiagnosticsPayload } from './core/diagnostics.ts';
76
+ import { buildDownlinkHealth as buildDownlinkHealthFromRuntime } from './core/downlink-health.ts';
77
+ import { buildExtendedDiagnostics as buildExtendedDiagnosticsFromRuntime } from './core/extended-diagnostics.ts';
78
+ import { observeLeaseState, matchesTransferOwner as matchesTransferOwnerFromRuntime } from './core/lease-state.ts';
28
79
  import { emitBncrLog, emitBncrLogLine } from './core/logging.ts';
29
- import { buildBncrPermissionSummary } from './core/permissions.ts';
30
80
  import { resolveBncrChannelPolicy } from './core/policy.ts';
31
- import { probeBncrAccount } from './core/probe.ts';
81
+ import {
82
+ buildRegisterTraceSummary as buildRegisterTraceSummaryFromEntries,
83
+ classifyRegisterTrace as classifyRegisterTraceFromStack,
84
+ } from './core/register-trace.ts';
32
85
  import {
33
86
  buildAccountRuntimeSnapshot,
87
+ buildAccountStatusSnapshot,
34
88
  buildIntegratedDiagnostics as buildIntegratedDiagnosticsFromRuntime,
35
89
  buildStatusHeadlineFromRuntime,
36
90
  buildStatusMetaFromRuntime,
@@ -66,6 +120,194 @@ import {
66
120
  buildBncrMediaOutboundFrame,
67
121
  resolveBncrOutboundMessageType,
68
122
  } from './messaging/outbound/media.ts';
123
+ import {
124
+ buildEnqueueFromReplyDebugInfo,
125
+ buildFlushDebugInfo,
126
+ buildOutboxAckDebugInfo,
127
+ buildOutboxPushOkDebugInfo,
128
+ buildOutboxPushSkipDebugInfo,
129
+ buildOutboxRouteSelectDebugInfo,
130
+ buildOutboxScheduleDebugInfo,
131
+ buildPushFailureDebugInfo,
132
+ buildReplyMediaFallbackDebugInfo,
133
+ buildRetryRerouteDebugInfo,
134
+ } from './messaging/outbound/diagnostics.ts';
135
+
136
+ function buildInboundAcceptedLifecycleDebugInfo(args: {
137
+ stage: 'accepted';
138
+ bridge: string;
139
+ accountId: string;
140
+ connId: string;
141
+ clientId?: string;
142
+ outboundReady: boolean;
143
+ preferredForOutbound: boolean;
144
+ inboundOnly: boolean;
145
+ onlineAfterSeen: boolean;
146
+ recentInboundReachable: boolean;
147
+ activeConnectionKey: string | null;
148
+ activeConnections: Array<{
149
+ connId: string;
150
+ clientId?: string;
151
+ connectedAt: number;
152
+ lastSeenAt: number;
153
+ }>;
154
+ }) {
155
+ return {
156
+ stage: args.stage,
157
+ bridge: args.bridge,
158
+ accountId: args.accountId,
159
+ connId: args.connId,
160
+ clientId: args.clientId,
161
+ outboundReady: args.outboundReady,
162
+ preferredForOutbound: args.preferredForOutbound,
163
+ inboundOnly: args.inboundOnly,
164
+ onlineAfterSeen: args.onlineAfterSeen,
165
+ recentInboundReachable: args.recentInboundReachable,
166
+ activeConnectionKey: args.activeConnectionKey,
167
+ activeConnections: args.activeConnections,
168
+ };
169
+ }
170
+
171
+ function resolveInboundSessionContext(args: {
172
+ cfg: any;
173
+ accountId: string;
174
+ peer: { kind: string } & Record<string, unknown>;
175
+ route: BncrRoute;
176
+ sessionKeyFromRoute?: string;
177
+ canonicalAgentId: string;
178
+ taskKey?: string;
179
+ text: string;
180
+ extractedText?: string;
181
+ resolveAgentRoute: (params: { cfg: any; channel: string; accountId: string; peer: unknown }) => {
182
+ sessionKey: string;
183
+ };
184
+ }) {
185
+ const resolvedRoute = args.resolveAgentRoute({
186
+ cfg: args.cfg,
187
+ channel: CHANNEL_ID,
188
+ accountId: args.accountId,
189
+ peer: args.peer,
190
+ });
191
+ const baseSessionKey =
192
+ normalizeInboundSessionKey(args.sessionKeyFromRoute, args.route, args.canonicalAgentId) ||
193
+ resolvedRoute.sessionKey;
194
+ const taskSessionKey = withTaskSessionKey(baseSessionKey, args.taskKey);
195
+ return {
196
+ resolvedRoute,
197
+ baseSessionKey,
198
+ taskSessionKey,
199
+ sessionKey: taskSessionKey || baseSessionKey,
200
+ inboundText: asString(args.extractedText || args.text || ''),
201
+ };
202
+ }
203
+
204
+ function buildInboundResponsePayload(
205
+ args:
206
+ | {
207
+ kind: 'stale-ignored';
208
+ accountId: string;
209
+ msgId?: string | null;
210
+ }
211
+ | {
212
+ kind: 'invalid-peer';
213
+ }
214
+ | {
215
+ kind: 'duplicated';
216
+ accountId: string;
217
+ msgId?: string | null;
218
+ }
219
+ | {
220
+ kind: 'gate-denied';
221
+ accountId: string;
222
+ msgId?: string | null;
223
+ reason: string;
224
+ }
225
+ | {
226
+ kind: 'accepted';
227
+ accountId: string;
228
+ sessionKey: string;
229
+ msgId?: string | null;
230
+ taskKey?: string | null;
231
+ },
232
+ ) {
233
+ switch (args.kind) {
234
+ case 'stale-ignored':
235
+ return {
236
+ accepted: false,
237
+ stale: true,
238
+ ignored: true,
239
+ accountId: args.accountId,
240
+ msgId: args.msgId ?? null,
241
+ };
242
+ case 'invalid-peer':
243
+ return { error: 'platform/groupId/userId required' };
244
+ case 'duplicated':
245
+ return {
246
+ accepted: true,
247
+ duplicated: true,
248
+ accountId: args.accountId,
249
+ msgId: args.msgId ?? null,
250
+ };
251
+ case 'gate-denied':
252
+ return {
253
+ accepted: false,
254
+ accountId: args.accountId,
255
+ msgId: args.msgId ?? null,
256
+ reason: args.reason,
257
+ };
258
+ case 'accepted':
259
+ return {
260
+ accepted: true,
261
+ accountId: args.accountId,
262
+ sessionKey: args.sessionKey,
263
+ msgId: args.msgId ?? null,
264
+ taskKey: args.taskKey ?? null,
265
+ };
266
+ }
267
+ }
268
+ import {
269
+ buildMediaTextFallback,
270
+ type MediaDedupeCacheEntry,
271
+ normalizeMessageText,
272
+ normalizeReplyToId,
273
+ } from './messaging/outbound/media-dedupe.ts';
274
+ import {
275
+ buildReplyTextOutboxEntry,
276
+ enqueueNormalizedReplyPayload,
277
+ enqueueReplyMediaFallbackTextEntry,
278
+ enqueueReplyMediaFileTransferEntry,
279
+ enqueueSingleReplyMediaEntry,
280
+ enqueueReplyTextEntry,
281
+ hasReplyMediaEntries,
282
+ normalizeReplyPayload,
283
+ type NormalizedReplyPayload,
284
+ type ReplyMediaEntriesParams,
285
+ type ReplyMediaFileTransferParams,
286
+ type ReplyPayloadInput,
287
+ } from './messaging/outbound/reply-enqueue.ts';
288
+ import {
289
+ OUTBOUND_DEGRADE_REASON,
290
+ OUTBOUND_FLUSH_REASON,
291
+ OUTBOUND_FLUSH_TRIGGER,
292
+ OUTBOUND_SCHEDULE_SOURCE,
293
+ OUTBOUND_TERMINAL_REASON,
294
+ } from './messaging/outbound/reasons.ts';
295
+ import {
296
+ buildOutboxOnlineDebugInfo,
297
+ clampOutboxDrainDelay,
298
+ computeNextOutboxDelay,
299
+ computeOutboxRetryWait,
300
+ findDueOutboxEntry,
301
+ listAccountOutboxEntries,
302
+ selectOutboxFileTransferRouteCandidates,
303
+ selectOutboxRouteCandidates,
304
+ selectOutboxTargetAccounts,
305
+ updateMinOutboxDelay,
306
+ } from './messaging/outbound/queue-selectors.ts';
307
+ import {
308
+ computePushFailureDecision,
309
+ computeRetryRerouteDecision,
310
+ } from './messaging/outbound/retry-policy.ts';
69
311
  import { sendBncrMedia, sendBncrText } from './messaging/outbound/send.ts';
70
312
  import { resolveBncrOutboundSessionRoute } from './messaging/outbound/session-route.ts';
71
313
  import {
@@ -83,6 +325,8 @@ const RECENT_INBOUND_SEND_WINDOW_MS = 60_000;
83
325
  const MAX_RETRY = 10;
84
326
  const PUSH_DRAIN_INTERVAL_MS = 500;
85
327
  const PUSH_ACK_TIMEOUT_MS = 30_000;
328
+ const OUTBOUND_READY_TTL_MS = 30_000;
329
+ const PREFERRED_OUTBOUND_TTL_MS = 12_000;
86
330
  const FILE_FORCE_CHUNK = true; // 统一走 WS 分块,保留 base64 仅作兜底
87
331
  const FILE_INLINE_THRESHOLD = 5 * 1024 * 1024; // fallback 阈值(仅 FILE_FORCE_CHUNK=false 时生效)
88
332
  const FILE_CHUNK_SIZE = 256 * 1024; // 256KB
@@ -246,6 +490,7 @@ function normalizeBncrSendParams(input: {
246
490
  };
247
491
  }
248
492
 
493
+
249
494
  function now() {
250
495
  return Date.now();
251
496
  }
@@ -333,6 +578,7 @@ class BncrBridgeRuntime {
333
578
  private api: OpenClawPluginApi;
334
579
  private statePath: string | null = null;
335
580
  private bridgeId = `${process.pid}-${Math.random().toString(16).slice(2, 8)}`;
581
+ private recentMediaDedupeBySession = new Map<string, Map<string, MediaDedupeCacheEntry>>();
336
582
  private gatewayPid = process.pid;
337
583
  private registerCount = 0;
338
584
  private apiGeneration = 0;
@@ -409,7 +655,11 @@ class BncrBridgeRuntime {
409
655
  private lastActivityByAccount = new Map<string, number>();
410
656
  private lastInboundByAccount = new Map<string, number>();
411
657
  private lastOutboundByAccount = new Map<string, number>();
658
+ private lastAckOkByAccount = new Map<string, number>();
659
+ private lastAckTimeoutByAccount = new Map<string, number>();
660
+ private ackTimeoutCountByAccount = new Map<string, number>();
412
661
  private channelAccountTimers = new Map<string, NodeJS.Timeout>();
662
+ private logDedupeState = new Map<string, { at: number; sig: string }>();
413
663
  private canonicalAgentId: string | null = null;
414
664
  private canonicalAgentSource: 'startup' | 'runtime' | 'fallback-main' | null = null;
415
665
  private canonicalAgentResolvedAt: number | null = null;
@@ -425,6 +675,11 @@ class BncrBridgeRuntime {
425
675
  private pushTimer: NodeJS.Timeout | null = null;
426
676
  private pushDrainRunningAccounts = new Set<string>();
427
677
  private messageAckWaiters = new Map<
678
+ // Refactor boundary note (message ACK runtime):
679
+ // These waiters are part of the outbound message-ack lifecycle, not just a utility map.
680
+ // They are coupled to shutdown cleanup, resolveMessageAck, waitForMessageAck, outbox retry
681
+ // decisions, and diagnostics counts. Any future extraction should move lifecycle tests first,
682
+ // then move storage + resolver/wait APIs together rather than partially splitting the map only.
428
683
  string,
429
684
  {
430
685
  resolve: (result: 'acked' | 'timeout') => void;
@@ -470,6 +725,102 @@ class BncrBridgeRuntime {
470
725
  emitBncrLog('error', scope, message, options, () => this.isDebugEnabled());
471
726
  }
472
727
 
728
+ private buildDebugJsonMessage(event: string, payload: Record<string, unknown>) {
729
+ return `${event} ${JSON.stringify(payload)}`;
730
+ }
731
+
732
+ private logInfoJson(
733
+ scope: string | undefined,
734
+ event: string,
735
+ payload: Record<string, unknown>,
736
+ options?: { debugOnly?: boolean },
737
+ ) {
738
+ this.logInfo(scope, this.buildDebugJsonMessage(event, payload), options);
739
+ }
740
+
741
+ private logWarnJson(
742
+ scope: string | undefined,
743
+ event: string,
744
+ payload: Record<string, unknown>,
745
+ options?: { debugOnly?: boolean },
746
+ ) {
747
+ this.logWarn(scope, this.buildDebugJsonMessage(event, payload), options);
748
+ }
749
+
750
+ private logErrorJson(
751
+ scope: string | undefined,
752
+ event: string,
753
+ payload: Record<string, unknown>,
754
+ options?: { debugOnly?: boolean },
755
+ ) {
756
+ this.logError(scope, this.buildDebugJsonMessage(event, payload), options);
757
+ }
758
+
759
+ private shouldEmitDedupLog(key: string, sig: string, windowMs = 5 * 60 * 1000) {
760
+ const t = now();
761
+ const prev = this.logDedupeState.get(key) || null;
762
+ if (prev && prev.sig === sig && t - prev.at < windowMs) return false;
763
+ this.logDedupeState.set(key, { at: t, sig });
764
+ return true;
765
+ }
766
+
767
+ private logInfoDedup(
768
+ scope: string | undefined,
769
+ message: string,
770
+ options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
771
+ ) {
772
+ if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
773
+ this.logInfo(scope, message, { debugOnly: options.debugOnly });
774
+ }
775
+
776
+ private logWarnDedup(
777
+ scope: string | undefined,
778
+ message: string,
779
+ options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
780
+ ) {
781
+ if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
782
+ this.logWarn(scope, message, { debugOnly: options.debugOnly });
783
+ }
784
+
785
+ private logErrorDedup(
786
+ scope: string | undefined,
787
+ message: string,
788
+ options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
789
+ ) {
790
+ if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
791
+ this.logError(scope, message, { debugOnly: options.debugOnly });
792
+ }
793
+
794
+ private logInfoDedupJson(
795
+ scope: string | undefined,
796
+ event: string,
797
+ payload: Record<string, unknown>,
798
+ options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
799
+ ) {
800
+ if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
801
+ this.logInfoJson(scope, event, payload, { debugOnly: options.debugOnly });
802
+ }
803
+
804
+ private logWarnDedupJson(
805
+ scope: string | undefined,
806
+ event: string,
807
+ payload: Record<string, unknown>,
808
+ options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
809
+ ) {
810
+ if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
811
+ this.logWarnJson(scope, event, payload, { debugOnly: options.debugOnly });
812
+ }
813
+
814
+ private logErrorDedupJson(
815
+ scope: string | undefined,
816
+ event: string,
817
+ payload: Record<string, unknown>,
818
+ options: { key: string; sig: string; debugOnly?: boolean; windowMs?: number },
819
+ ) {
820
+ if (!this.shouldEmitDedupLog(options.key, options.sig, options.windowMs)) return;
821
+ this.logErrorJson(scope, event, payload, { debugOnly: options.debugOnly });
822
+ }
823
+
473
824
  private summarizeTextPreview(raw: string, limit = 8) {
474
825
  const compact = asString(raw || '')
475
826
  .replace(/\s+/g, ' ')
@@ -496,11 +847,15 @@ class BncrBridgeRuntime {
496
847
  }
497
848
 
498
849
  private logOutboundSummary(entry: OutboxEntry) {
499
- const msg = (entry.payload as any)?.message || {};
500
- const type = asString(msg.type || (entry.payload as any)?.type || 'unknown');
501
- const text = asString(msg.msg || '');
502
- const preview = this.summarizeTextPreview(text);
503
- this.logInfo('outbound', [type, this.summarizeScope(entry.route), preview].join('|'));
850
+ this.logInfo(
851
+ 'outbound',
852
+ summarizeOutboxEntry({
853
+ entry,
854
+ asString,
855
+ formatDisplayScope,
856
+ summarizeTextPreview: (raw, limit) => this.summarizeTextPreview(raw, limit),
857
+ }),
858
+ );
504
859
  }
505
860
 
506
861
  private clearChannelAccountWorker(accountId: string, reason: string) {
@@ -516,41 +871,6 @@ class BncrBridgeRuntime {
516
871
  return true;
517
872
  }
518
873
 
519
- private classifyRegisterTrace(stack: string) {
520
- if (
521
- stack.includes('prepareSecretsRuntimeSnapshot') ||
522
- stack.includes('resolveRuntimeWebTools') ||
523
- stack.includes('resolvePluginWebSearchProviders')
524
- ) {
525
- return 'runtime/webtools';
526
- }
527
- if (stack.includes('startGatewayServer') || stack.includes('loadGatewayPlugins')) {
528
- return 'gateway/startup';
529
- }
530
- if (stack.includes('resolvePluginImplicitProviders')) {
531
- return 'provider/discovery/implicit';
532
- }
533
- if (stack.includes('resolvePluginDiscoveryProviders')) {
534
- return 'provider/discovery/discovery';
535
- }
536
- if (stack.includes('resolvePluginProviders')) {
537
- return 'provider/discovery/providers';
538
- }
539
- return 'other';
540
- }
541
-
542
- private dominantRegisterBucket(sourceBuckets: Record<string, number>) {
543
- let winner: string | null = null;
544
- let winnerCount = -1;
545
- for (const [bucket, count] of Object.entries(sourceBuckets)) {
546
- if (count > winnerCount) {
547
- winner = bucket;
548
- winnerCount = count;
549
- }
550
- }
551
- return winner;
552
- }
553
-
554
874
  private captureDriftSnapshot(
555
875
  summary: ReturnType<BncrBridgeRuntime['buildRegisterTraceSummary']>,
556
876
  ) {
@@ -570,41 +890,11 @@ class BncrBridgeRuntime {
570
890
  }
571
891
 
572
892
  private buildRegisterTraceSummary() {
573
- const buckets: Record<string, number> = {};
574
- let warmupCount = 0;
575
- let postWarmupCount = 0;
576
- let unexpectedRegisterAfterWarmup = false;
577
- let lastUnexpectedRegisterAt: number | null = null;
578
- const baseline = this.firstRegisterAt;
579
-
580
- for (const trace of this.registerTraceRecent) {
581
- buckets[trace.stackBucket] = (buckets[trace.stackBucket] || 0) + 1;
582
- const isWarmup = baseline != null && trace.ts - baseline <= REGISTER_WARMUP_WINDOW_MS;
583
- if (isWarmup) {
584
- warmupCount += 1;
585
- } else {
586
- postWarmupCount += 1;
587
- unexpectedRegisterAfterWarmup = true;
588
- lastUnexpectedRegisterAt = trace.ts;
589
- }
590
- }
591
-
592
- const dominantBucket = this.dominantRegisterBucket(buckets);
593
- const likelyRuntimeRegistryDrift = postWarmupCount > 0;
594
- const likelyStartupFanoutOnly = warmupCount > 0 && postWarmupCount === 0;
595
-
596
- return {
597
- startupWindowMs: REGISTER_WARMUP_WINDOW_MS,
598
- traceWindowSize: this.registerTraceRecent.length,
599
- sourceBuckets: buckets,
600
- dominantBucket,
601
- warmupRegisterCount: warmupCount,
602
- postWarmupRegisterCount: postWarmupCount,
603
- unexpectedRegisterAfterWarmup,
604
- lastUnexpectedRegisterAt,
605
- likelyRuntimeRegistryDrift,
606
- likelyStartupFanoutOnly,
607
- };
893
+ return buildRegisterTraceSummaryFromEntries({
894
+ traceRecent: this.registerTraceRecent,
895
+ firstRegisterAt: this.firstRegisterAt,
896
+ warmupWindowMs: REGISTER_WARMUP_WINDOW_MS,
897
+ });
608
898
  }
609
899
 
610
900
  noteRegister(meta: {
@@ -635,7 +925,7 @@ class BncrBridgeRuntime {
635
925
  .map((line) => line.trim())
636
926
  .filter(Boolean)
637
927
  .join(' <- ');
638
- const stackBucket = this.classifyRegisterTrace(stack);
928
+ const stackBucket = classifyRegisterTraceFromStack(stack);
639
929
 
640
930
  const trace = {
641
931
  ts,
@@ -706,48 +996,21 @@ class BncrBridgeRuntime {
706
996
  const leaseId = typeof params.leaseId === 'string' ? params.leaseId.trim() : '';
707
997
  const connectionEpoch =
708
998
  typeof params.connectionEpoch === 'number' ? params.connectionEpoch : undefined;
709
- if (!leaseId && connectionEpoch == null) return { stale: false, reason: 'missing' as const };
710
- const staleByLease =
711
- !!leaseId && this.primaryLeaseId != null && leaseId !== this.primaryLeaseId;
712
- const staleByEpoch =
713
- connectionEpoch != null &&
714
- this.connectionEpoch > 0 &&
715
- connectionEpoch !== this.connectionEpoch;
716
- const stale = staleByLease || staleByEpoch;
717
- if (!stale) return { stale: false, reason: 'ok' as const };
718
- this.staleCounters.lastStaleAt = now();
719
- switch (kind) {
720
- case 'connect':
721
- this.staleCounters.staleConnect += 1;
722
- break;
723
- case 'inbound':
724
- this.staleCounters.staleInbound += 1;
725
- break;
726
- case 'activity':
727
- this.staleCounters.staleActivity += 1;
728
- break;
729
- case 'ack':
730
- this.staleCounters.staleAck += 1;
731
- break;
732
- case 'file.init':
733
- this.staleCounters.staleFileInit += 1;
734
- break;
735
- case 'file.chunk':
736
- this.staleCounters.staleFileChunk += 1;
737
- break;
738
- case 'file.complete':
739
- this.staleCounters.staleFileComplete += 1;
740
- break;
741
- case 'file.abort':
742
- this.staleCounters.staleFileAbort += 1;
743
- break;
744
- }
999
+ const observed = observeLeaseState({
1000
+ kind,
1001
+ params,
1002
+ currentLeaseId: this.primaryLeaseId,
1003
+ currentConnectionEpoch: this.connectionEpoch,
1004
+ now: now(),
1005
+ staleCounters: this.staleCounters,
1006
+ });
1007
+ if (!observed.stale) return observed;
745
1008
  this.logWarn(
746
1009
  'stale',
747
1010
  `observed kind=${kind} lease=${leaseId || '-'} epoch=${connectionEpoch ?? '-'} currentLease=${this.primaryLeaseId || '-'} currentEpoch=${this.connectionEpoch}`,
748
1011
  { debugOnly: true },
749
1012
  );
750
- return { stale: true, reason: 'mismatch' as const };
1013
+ return observed;
751
1014
  }
752
1015
 
753
1016
  private shouldIgnoreStaleEvent(params: {
@@ -780,19 +1043,14 @@ class BncrBridgeRuntime {
780
1043
  connId: string;
781
1044
  clientId?: string;
782
1045
  }) {
783
- const sameConn = !!params.ownerConnId && params.ownerConnId === params.connId;
784
- const sameClient =
785
- !params.ownerConnId &&
786
- !!params.ownerClientId &&
787
- !!params.clientId &&
788
- params.ownerClientId === params.clientId;
789
- return sameConn || sameClient;
1046
+ return matchesTransferOwnerFromRuntime(params);
790
1047
  }
791
1048
 
792
1049
  private buildExtendedDiagnostics(accountId: string) {
793
- const diagnostics = this.buildIntegratedDiagnostics(accountId) as Record<string, any>;
794
- return {
795
- ...diagnostics,
1050
+ const acc = normalizeAccountId(accountId);
1051
+ const diagnostics = this.buildIntegratedDiagnostics(acc) as Record<string, any>;
1052
+ return buildExtendedDiagnosticsFromRuntime({
1053
+ diagnostics,
796
1054
  register: {
797
1055
  bridgeId: this.bridgeId,
798
1056
  gatewayPid: this.gatewayPid,
@@ -805,12 +1063,12 @@ class BncrBridgeRuntime {
805
1063
  lastRegisterAt: this.lastRegisterAt,
806
1064
  lastApiRebindAt: this.lastApiRebindAt,
807
1065
  apiGeneration: this.apiGeneration,
808
- traceRecent: this.registerTraceRecent.slice(),
1066
+ traceRecent: this.registerTraceRecent,
809
1067
  traceSummary: this.buildRegisterTraceSummary(),
810
1068
  lastDriftSnapshot: this.lastDriftSnapshot,
811
1069
  },
812
1070
  connection: {
813
- active: this.activeConnectionCount(accountId),
1071
+ active: this.activeConnectionCount(acc),
814
1072
  primaryLeaseId: this.primaryLeaseId,
815
1073
  primaryEpoch: this.connectionEpoch || null,
816
1074
  acceptedConnections: this.acceptedConnections,
@@ -839,8 +1097,8 @@ class BncrBridgeRuntime {
839
1097
  staleRejectFile: false,
840
1098
  },
841
1099
  },
842
- stale: { ...this.staleCounters },
843
- };
1100
+ stale: this.staleCounters,
1101
+ });
844
1102
  }
845
1103
 
846
1104
  isDebugEnabled(): boolean {
@@ -1039,27 +1297,22 @@ class BncrBridgeRuntime {
1039
1297
  }
1040
1298
 
1041
1299
  private buildIntegratedDiagnostics(accountId: string) {
1300
+ return buildIntegratedDiagnosticsFromRuntime(this.buildRuntimeStatusInput(accountId));
1301
+ }
1302
+
1303
+ private buildDownlinkHealth(accountId: string) {
1042
1304
  const acc = normalizeAccountId(accountId);
1043
- return buildIntegratedDiagnosticsFromRuntime({
1305
+ return buildDownlinkHealthFromRuntime({
1044
1306
  accountId: acc,
1045
- connected: this.isOnline(acc),
1046
- pending: Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length,
1047
- deadLetter: this.deadLetter.filter((v) => v.accountId === acc).length,
1048
- activeConnections: this.activeConnectionCount(acc),
1049
- connectEvents: this.getCounter(this.connectEventsByAccount, acc),
1050
- inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
1051
- activityEvents: this.getCounter(this.activityEventsByAccount, acc),
1052
- ackEvents: this.getCounter(this.ackEventsByAccount, acc),
1053
- startedAt: this.startedAt,
1054
- lastSession: this.lastSessionByAccount.get(acc) || null,
1055
- lastActivityAt: this.lastActivityByAccount.get(acc) || null,
1307
+ now: now(),
1308
+ outboxEntries: this.outbox.values(),
1309
+ lastAckOkAt: this.lastAckOkByAccount.get(acc) || null,
1310
+ lastAckTimeoutAt: this.lastAckTimeoutByAccount.get(acc) || null,
1311
+ recentAckTimeoutCount: this.getCounter(this.ackTimeoutCountByAccount, acc),
1312
+ activeConnectionCount: this.activeConnectionCount(acc),
1056
1313
  lastInboundAt: this.lastInboundByAccount.get(acc) || null,
1057
- lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
1058
- sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc)
1059
- .length,
1060
- invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
1061
- legacyAccountResidue: this.countLegacyAccountResidue(acc),
1062
- channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
1314
+ lastActivityAt: this.lastActivityByAccount.get(acc) || null,
1315
+ onlineByConn: this.isOnline(acc),
1063
1316
  });
1064
1317
  }
1065
1318
 
@@ -1338,26 +1591,165 @@ class BncrBridgeRuntime {
1338
1591
  const acc = normalizeAccountId(accountId);
1339
1592
  const t = now();
1340
1593
  const primaryKey = this.activeConnectionByAccount.get(acc);
1341
- if (!primaryKey) return null;
1342
- const primary = this.connections.get(primaryKey);
1343
- if (!primary?.connId) return null;
1344
- if (t - primary.lastSeenAt > CONNECT_TTL_MS) return null;
1345
- return primary;
1594
+ const primary = primaryKey ? this.connections.get(primaryKey) : null;
1595
+
1596
+ const isEligible = (conn: BncrConnection | null | undefined): conn is BncrConnection & {
1597
+ outboundReadyUntil?: number;
1598
+ preferredForOutboundUntil?: number;
1599
+ inboundOnly?: boolean;
1600
+ } => {
1601
+ if (!conn?.connId) return false;
1602
+ if (t - conn.lastSeenAt > CONNECT_TTL_MS) return false;
1603
+ if ((conn as any).inboundOnly === true) return false;
1604
+ return true;
1605
+ };
1606
+
1607
+ const recentInboundConnIds = this.resolveRecentInboundConnIds(acc);
1608
+ const candidateScore = (conn: BncrConnection) => {
1609
+ const preferredForOutboundUntil = Number((conn as any).preferredForOutboundUntil || 0);
1610
+ const outboundReadyUntil = Number((conn as any).outboundReadyUntil || 0);
1611
+ const lastPushTimeoutAt = Number((conn as any).lastPushTimeoutAt || 0);
1612
+ const lastAckOkAt = Number((conn as any).lastAckOkAt || 0);
1613
+ const pushFailureScore = Number((conn as any).pushFailureScore || 0);
1614
+ const recentTimeoutPenalty = lastPushTimeoutAt > 0 && t - lastPushTimeoutAt <= 30_000 ? 1 : 0;
1615
+ return {
1616
+ preferred: preferredForOutboundUntil > t ? 1 : 0,
1617
+ ready: outboundReadyUntil > t ? 1 : 0,
1618
+ recentInbound: recentInboundConnIds.has(conn.connId) ? 1 : 0,
1619
+ recentTimeoutPenalty,
1620
+ pushFailureScore,
1621
+ lastAckOkAt,
1622
+ lastPushTimeoutAt,
1623
+ lastSeenAt: conn.lastSeenAt,
1624
+ connectedAt: conn.connectedAt,
1625
+ };
1626
+ };
1627
+
1628
+ if (isEligible(primary)) {
1629
+ const score = candidateScore(primary);
1630
+ if (score.preferred || score.ready) return primary;
1631
+ }
1632
+
1633
+ const candidates = Array.from(this.connections.values())
1634
+ .filter((c): c is BncrConnection => c.accountId === acc)
1635
+ .filter((c) => isEligible(c))
1636
+ .sort((a, b) => {
1637
+ const sa = candidateScore(a);
1638
+ const sb = candidateScore(b);
1639
+ if (sb.preferred !== sa.preferred) return sb.preferred - sa.preferred;
1640
+ if (sb.ready !== sa.ready) return sb.ready - sa.ready;
1641
+ if (sa.recentTimeoutPenalty !== sb.recentTimeoutPenalty) return sa.recentTimeoutPenalty - sb.recentTimeoutPenalty;
1642
+ if (sa.pushFailureScore !== sb.pushFailureScore) return sa.pushFailureScore - sb.pushFailureScore;
1643
+ if (sb.lastAckOkAt !== sa.lastAckOkAt) return sb.lastAckOkAt - sa.lastAckOkAt;
1644
+ if (sa.lastPushTimeoutAt !== sb.lastPushTimeoutAt) return sa.lastPushTimeoutAt - sb.lastPushTimeoutAt;
1645
+ if (sb.recentInbound !== sa.recentInbound) return sb.recentInbound - sa.recentInbound;
1646
+ if (sb.lastSeenAt !== sa.lastSeenAt) return sb.lastSeenAt - sa.lastSeenAt;
1647
+ return sb.connectedAt - sa.connectedAt;
1648
+ });
1649
+
1650
+ const next = candidates[0] || null;
1651
+ if (!next) return null;
1652
+
1653
+ const nextKey = this.connectionKey(acc, next.clientId);
1654
+ if (primaryKey !== nextKey) {
1655
+ this.activeConnectionByAccount.set(acc, nextKey);
1656
+ this.logInfo(
1657
+ 'connection',
1658
+ `owner:promote ${JSON.stringify({
1659
+ bridge: this.bridgeId,
1660
+ accountId: acc,
1661
+ previousActiveKey: primaryKey || null,
1662
+ previousActiveConn: primary || null,
1663
+ nextActiveKey: nextKey,
1664
+ nextActiveConn: {
1665
+ connId: next.connId,
1666
+ clientId: next.clientId,
1667
+ connectedAt: next.connectedAt,
1668
+ lastSeenAt: next.lastSeenAt,
1669
+ outboundReadyUntil: (next as any).outboundReadyUntil || null,
1670
+ preferredForOutboundUntil: (next as any).preferredForOutboundUntil || null,
1671
+ inboundOnly: (next as any).inboundOnly === true,
1672
+ },
1673
+ reason: 'better-outbound-candidate',
1674
+ })}`,
1675
+ { debugOnly: true },
1676
+ );
1677
+ }
1678
+
1679
+ return next;
1346
1680
  }
1347
1681
 
1348
1682
  private resolvePushConnIds(accountId: string): Set<string> {
1683
+ // Refactor boundary note (route selection):
1684
+ // This selector is not a pure lookup. It combines active-owner preference, outbound readiness,
1685
+ // preferred-for-outbound windows, recent inbound reachability, timeout penalties, and a final
1686
+ // fallback pass over live connections. If this logic is extracted later, first isolate the
1687
+ // candidate scoring / ordering into a pure function and keep the current fallback semantics intact.
1349
1688
  const acc = normalizeAccountId(accountId);
1350
1689
  const t = now();
1351
1690
  const connIds = new Set<string>();
1352
1691
 
1692
+ const isEligible = (conn: BncrConnection | null | undefined): conn is BncrConnection & {
1693
+ outboundReadyUntil?: number;
1694
+ preferredForOutboundUntil?: number;
1695
+ inboundOnly?: boolean;
1696
+ } => {
1697
+ if (!conn?.connId) return false;
1698
+ if (t - conn.lastSeenAt > CONNECT_TTL_MS) return false;
1699
+ if ((conn as any).inboundOnly === true) return false;
1700
+ return true;
1701
+ };
1702
+
1703
+ const recentInboundConnIds = this.resolveRecentInboundConnIds(acc);
1704
+ const candidateScore = (conn: BncrConnection) => {
1705
+ const preferredForOutboundUntil = Number((conn as any).preferredForOutboundUntil || 0);
1706
+ const outboundReadyUntil = Number((conn as any).outboundReadyUntil || 0);
1707
+ const lastPushTimeoutAt = Number((conn as any).lastPushTimeoutAt || 0);
1708
+ const lastAckOkAt = Number((conn as any).lastAckOkAt || 0);
1709
+ const pushFailureScore = Number((conn as any).pushFailureScore || 0);
1710
+ const recentTimeoutPenalty = lastPushTimeoutAt > 0 && t - lastPushTimeoutAt <= 30_000 ? 1 : 0;
1711
+ return {
1712
+ preferred: preferredForOutboundUntil > t ? 1 : 0,
1713
+ ready: outboundReadyUntil > t ? 1 : 0,
1714
+ recentInbound: recentInboundConnIds.has(conn.connId) ? 1 : 0,
1715
+ recentTimeoutPenalty,
1716
+ pushFailureScore,
1717
+ lastAckOkAt,
1718
+ lastPushTimeoutAt,
1719
+ lastSeenAt: conn.lastSeenAt,
1720
+ connectedAt: conn.connectedAt,
1721
+ };
1722
+ };
1723
+
1353
1724
  const primaryKey = this.activeConnectionByAccount.get(acc);
1354
1725
  if (primaryKey) {
1355
1726
  const primary = this.connections.get(primaryKey);
1356
- if (primary?.connId && t - primary.lastSeenAt <= CONNECT_TTL_MS) {
1727
+ if (isEligible(primary)) {
1357
1728
  connIds.add(primary.connId);
1358
1729
  }
1359
1730
  }
1360
1731
 
1732
+ const candidates = Array.from(this.connections.values())
1733
+ .filter((c): c is BncrConnection => c.accountId === acc)
1734
+ .filter((c) => isEligible(c))
1735
+ .sort((a, b) => {
1736
+ const sa = candidateScore(a);
1737
+ const sb = candidateScore(b);
1738
+ if (sb.preferred !== sa.preferred) return sb.preferred - sa.preferred;
1739
+ if (sb.ready !== sa.ready) return sb.ready - sa.ready;
1740
+ if (sa.recentTimeoutPenalty !== sb.recentTimeoutPenalty) return sa.recentTimeoutPenalty - sb.recentTimeoutPenalty;
1741
+ if (sa.pushFailureScore !== sb.pushFailureScore) return sa.pushFailureScore - sb.pushFailureScore;
1742
+ if (sb.lastAckOkAt !== sa.lastAckOkAt) return sb.lastAckOkAt - sa.lastAckOkAt;
1743
+ if (sa.lastPushTimeoutAt !== sb.lastPushTimeoutAt) return sa.lastPushTimeoutAt - sb.lastPushTimeoutAt;
1744
+ if (sb.recentInbound !== sa.recentInbound) return sb.recentInbound - sa.recentInbound;
1745
+ if (sb.lastSeenAt !== sa.lastSeenAt) return sb.lastSeenAt - sa.lastSeenAt;
1746
+ return sb.connectedAt - sa.connectedAt;
1747
+ });
1748
+
1749
+ for (const c of candidates) {
1750
+ connIds.add(c.connId);
1751
+ }
1752
+
1361
1753
  if (connIds.size > 0) return connIds;
1362
1754
 
1363
1755
  for (const c of this.connections.values()) {
@@ -1372,44 +1764,61 @@ class BncrBridgeRuntime {
1372
1764
 
1373
1765
  private hasRecentInboundReachability(accountId: string): boolean {
1374
1766
  const acc = normalizeAccountId(accountId);
1375
- const t = now();
1376
- const lastInboundAt = this.lastInboundByAccount.get(acc) || 0;
1377
- const lastActivityAt = this.lastActivityByAccount.get(acc) || 0;
1378
- const lastReachableAt = Math.max(lastInboundAt, lastActivityAt);
1379
- return lastReachableAt > 0 && t - lastReachableAt <= RECENT_INBOUND_SEND_WINDOW_MS;
1767
+ return hasRecentInboundReachabilityFromRuntime({
1768
+ now: now(),
1769
+ windowMs: RECENT_INBOUND_SEND_WINDOW_MS,
1770
+ lastInboundAt: this.lastInboundByAccount.get(acc) || 0,
1771
+ lastActivityAt: this.lastActivityByAccount.get(acc) || 0,
1772
+ });
1380
1773
  }
1381
1774
 
1382
1775
  private resolveRecentInboundConnIds(accountId: string): Set<string> {
1383
1776
  const acc = normalizeAccountId(accountId);
1384
- const t = now();
1385
- const connIds = new Set<string>();
1386
- if (!this.hasRecentInboundReachability(acc)) return connIds;
1387
-
1388
- for (const c of this.connections.values()) {
1389
- if (c.accountId !== acc) continue;
1390
- if (!c.connId) continue;
1391
- if (t - c.lastSeenAt > CONNECT_TTL_MS * 2) continue;
1392
- connIds.add(c.connId);
1393
- }
1394
-
1395
- return connIds;
1777
+ return resolveRecentInboundConnIdsFromRuntime({
1778
+ accountId: acc,
1779
+ now: now(),
1780
+ connectTtlMs: CONNECT_TTL_MS,
1781
+ recentInboundReachable: this.hasRecentInboundReachability(acc),
1782
+ connections: this.connections.values(),
1783
+ });
1396
1784
  }
1397
1785
 
1398
1786
  private isRecentlyReachableConn(accountId: string, connId?: string, clientId?: string): boolean {
1399
1787
  const acc = normalizeAccountId(accountId);
1400
- const cid = asString(connId || '').trim();
1401
- const client = asString(clientId || '').trim() || undefined;
1402
- if (!cid) return false;
1788
+ const activeKey = this.activeConnectionByAccount.get(acc);
1789
+ const active = activeKey ? this.connections.get(activeKey) || null : null;
1790
+ return isRecentlyReachableConnFromRuntime({
1791
+ accountId: acc,
1792
+ connId,
1793
+ clientId,
1794
+ recentConnIds: this.resolveRecentInboundConnIds(acc),
1795
+ activeConnection: active,
1796
+ });
1797
+ }
1403
1798
 
1404
- const recentConnIds = this.resolveRecentInboundConnIds(acc);
1405
- if (recentConnIds.has(cid)) return true;
1799
+ private isRevalidatedAttemptedConn(entry: OutboxEntry, connId: string): boolean {
1800
+ const acc = normalizeAccountId(entry.accountId);
1801
+ const revalidated = getRevalidatedAttemptReason({
1802
+ entry,
1803
+ connId,
1804
+ accountId: acc,
1805
+ now: now(),
1806
+ connectTtlMs: CONNECT_TTL_MS,
1807
+ recentInboundReachable: this.hasRecentInboundReachability(acc),
1808
+ connections: this.connections.values(),
1809
+ });
1810
+ if (!revalidated) return false;
1406
1811
 
1407
- const activeKey = this.activeConnectionByAccount.get(acc);
1408
- if (!activeKey) return false;
1409
- const active = this.connections.get(activeKey);
1410
- if (!active?.connId) return false;
1411
- if (active.connId !== cid) return false;
1412
- if (client && active.clientId && active.clientId !== client) return false;
1812
+ this.logInfo(
1813
+ 'outbox',
1814
+ `revalidated-retry ${JSON.stringify({
1815
+ messageId: entry.messageId,
1816
+ accountId: acc,
1817
+ connId: String(connId || '').trim(),
1818
+ ...revalidated,
1819
+ })}`,
1820
+ { debugOnly: true },
1821
+ );
1413
1822
  return true;
1414
1823
  }
1415
1824
 
@@ -1455,282 +1864,1023 @@ class BncrBridgeRuntime {
1455
1864
  return retryableMarkers.some((marker) => msg.includes(marker));
1456
1865
  }
1457
1866
 
1458
- private buildFileTransferOutboxEntry(params: {
1459
- accountId: string;
1460
- sessionKey: string;
1461
- route: BncrRoute;
1867
+ private async pushFileTransferSuccessPath(args: {
1868
+ entry: OutboxEntry;
1869
+ meta: Record<string, unknown>;
1870
+ owner: ReturnType<BncrBridgeRuntime['resolveOutboxPushOwner']>;
1871
+ connIds: Iterable<string>;
1872
+ recentInboundReachable: boolean;
1873
+ routeReason: string;
1462
1874
  mediaUrl: string;
1463
- mediaLocalRoots?: readonly string[];
1464
- text?: string;
1465
- asVoice?: boolean;
1466
- audioAsVoice?: boolean;
1467
- kind?: 'tool' | 'block' | 'final';
1468
- replyToId?: string;
1469
- }): OutboxEntry {
1470
- const messageId = randomUUID();
1471
- return {
1472
- messageId,
1473
- accountId: normalizeAccountId(params.accountId),
1474
- sessionKey: params.sessionKey,
1475
- route: params.route,
1476
- payload: {
1477
- type: 'message.outbound',
1478
- sessionKey: params.sessionKey,
1479
- _meta: {
1480
- kind: 'file-transfer',
1481
- mediaUrl: params.mediaUrl,
1482
- mediaLocalRoots: params.mediaLocalRoots ? Array.from(params.mediaLocalRoots) : undefined,
1483
- text: asString(params.text || ''),
1484
- asVoice: params.asVoice === true,
1485
- audioAsVoice: params.audioAsVoice === true,
1486
- finalEvent: BNCR_PUSH_EVENT,
1487
- replyToId: asString(params.replyToId || '').trim() || undefined,
1488
- messageKind: params.kind,
1489
- },
1490
- },
1491
- createdAt: now(),
1492
- retryCount: 0,
1493
- nextAttemptAt: now(),
1494
- };
1875
+ }): Promise<void> {
1876
+ const media = await this.transferMediaToBncrClient({
1877
+ accountId: args.entry.accountId,
1878
+ sessionKey: args.entry.sessionKey,
1879
+ route: args.entry.route,
1880
+ mediaUrl: args.mediaUrl,
1881
+ mediaLocalRoots: Array.isArray(args.meta.mediaLocalRoots)
1882
+ ? args.meta.mediaLocalRoots.filter((v): v is string => typeof v === 'string')
1883
+ : undefined,
1884
+ });
1885
+ const frame = this.buildFileTransferOutboundFrame({
1886
+ entry: args.entry,
1887
+ meta: args.meta,
1888
+ media,
1889
+ mediaUrl: args.mediaUrl,
1890
+ });
1891
+
1892
+ this.gatewayContext!.broadcastToConnIds(
1893
+ BNCR_PUSH_EVENT,
1894
+ buildFileTransferBroadcastPayload({
1895
+ frame,
1896
+ messageId: args.entry.messageId,
1897
+ }),
1898
+ args.connIds,
1899
+ );
1900
+ this.logOutboxRouteSelect(
1901
+ buildFileTransferRouteSelectArgs({
1902
+ entry: args.entry,
1903
+ connIds: args.connIds,
1904
+ routeReason: args.routeReason,
1905
+ recentInboundReachable: args.recentInboundReachable,
1906
+ owner: args.owner,
1907
+ event: BNCR_PUSH_EVENT,
1908
+ }),
1909
+ );
1910
+ this.recordOutboxPushSuccess(
1911
+ buildFileTransferPushSuccessArgs({
1912
+ entry: args.entry,
1913
+ connIds: args.connIds,
1914
+ owner: args.owner,
1915
+ }),
1916
+ );
1917
+ this.logOutboxPushOkSummary(args.entry.messageId);
1918
+ this.logOutboxPushOk(
1919
+ buildFileTransferPushOkArgs({
1920
+ entry: args.entry,
1921
+ connIds: args.connIds,
1922
+ recentInboundReachable: args.recentInboundReachable,
1923
+ event: BNCR_PUSH_EVENT,
1924
+ }),
1925
+ );
1495
1926
  }
1496
1927
 
1497
- private async tryPushEntry(entry: OutboxEntry): Promise<boolean> {
1498
- const meta = isPlainObject(entry.payload?._meta) ? entry.payload._meta : null;
1499
- if (meta?.kind === 'file-transfer') {
1500
- const ctx = this.gatewayContext;
1501
- if (!ctx) {
1502
- entry.lastError = 'gateway context unavailable';
1503
- this.outbox.set(entry.messageId, entry);
1504
- this.logInfo(
1505
- 'outbox',
1506
- `push-skip ${JSON.stringify({
1507
- messageId: entry.messageId,
1508
- accountId: entry.accountId,
1509
- kind: 'file-transfer',
1510
- reason: 'no-gateway-context',
1511
- })}`,
1512
- { debugOnly: true },
1513
- );
1514
- return false;
1515
- }
1928
+ private handleFileTransferPushFailure(args: {
1929
+ entry: OutboxEntry;
1930
+ error: unknown;
1931
+ }) {
1932
+ this.recordOutboxPushFailure({
1933
+ entry: args.entry,
1934
+ error: args.error,
1935
+ fallbackError: 'file-transfer-error',
1936
+ persist: true,
1937
+ });
1938
+ const failure = resolveFileTransferFailureState({
1939
+ entry: args.entry,
1940
+ error: args.error,
1941
+ isRetryableFileTransferError: (value) => this.isRetryableFileTransferError(value),
1942
+ });
1943
+ this.logOutboxPushFailureSummary(args.entry.messageId, args.entry.lastError);
1944
+ this.logOutboxPushFailure(
1945
+ buildFileTransferPushFailureArgs({
1946
+ entry: args.entry,
1947
+ retryable: failure.retryable,
1948
+ }),
1949
+ );
1950
+ if (!failure.retryable) {
1951
+ this.moveToDeadLetter(args.entry, failure.deadLetterReason);
1952
+ }
1953
+ }
1516
1954
 
1517
- const owner = this.resolveOutboxPushOwner(entry.accountId);
1518
- let connIds = owner?.connId
1519
- ? new Set([owner.connId])
1520
- : this.resolvePushConnIds(entry.accountId);
1521
- const recentInboundReachable = this.hasRecentInboundReachability(entry.accountId);
1522
- if (!connIds.size && recentInboundReachable) {
1523
- connIds = this.resolveRecentInboundConnIds(entry.accountId);
1524
- }
1525
- if (!connIds.size) {
1526
- entry.lastError = 'no active bncr client for file chunk transfer';
1527
- this.outbox.set(entry.messageId, entry);
1528
- this.logInfo(
1529
- 'outbox',
1530
- `push-skip ${JSON.stringify({
1531
- messageId: entry.messageId,
1532
- accountId: entry.accountId,
1533
- kind: 'file-transfer',
1534
- reason: 'no-active-connection',
1535
- recentInboundReachable,
1536
- })}`,
1537
- { debugOnly: true },
1538
- );
1539
- return false;
1540
- }
1955
+ private handleFileTransferPushGuardFailure(args: {
1956
+ entry: OutboxEntry;
1957
+ guard: Exclude<ReturnType<typeof resolveFileTransferGuard>, { ok: true }>;
1958
+ }) {
1959
+ this.recordOutboxPrePushFailure({
1960
+ entry: args.entry,
1961
+ lastError: args.guard.lastError,
1962
+ });
1963
+ if (args.guard.reason === 'media-url-missing') {
1964
+ this.logOutboxPushFailure({
1965
+ messageId: args.entry.messageId,
1966
+ accountId: args.entry.accountId,
1967
+ retryCount: args.entry.retryCount,
1968
+ kind: 'file-transfer',
1969
+ lastError: args.entry.lastError,
1970
+ });
1971
+ return;
1972
+ }
1973
+ this.logOutboxPushSkip({
1974
+ messageId: args.entry.messageId,
1975
+ accountId: args.entry.accountId,
1976
+ kind: 'file-transfer',
1977
+ reason: args.guard.reason === 'no-gateway-context' ? 'no-gateway-context' : 'no-active-connection',
1978
+ recentInboundReachable:
1979
+ args.guard.reason === 'no-active-connection' ? args.guard.recentInboundReachable : undefined,
1980
+ });
1981
+ }
1541
1982
 
1542
- const mediaUrl = asString(meta.mediaUrl || '').trim();
1543
- if (!mediaUrl) {
1544
- entry.lastError = 'file transfer mediaUrl missing';
1545
- this.outbox.set(entry.messageId, entry);
1546
- this.logInfo(
1547
- 'outbox',
1548
- `push-fail ${JSON.stringify({
1549
- messageId: entry.messageId,
1550
- accountId: entry.accountId,
1551
- kind: 'file-transfer',
1552
- error: entry.lastError,
1553
- })}`,
1554
- { debugOnly: true },
1555
- );
1556
- return false;
1983
+ private async tryPushFileTransferEntry(
1984
+ entry: OutboxEntry,
1985
+ meta: Record<string, unknown>,
1986
+ ): Promise<boolean> {
1987
+ const ctx = this.gatewayContext;
1988
+ const owner = this.resolveOutboxPushOwner(entry.accountId);
1989
+ const selection = prepareFileTransferRouteSelection({
1990
+ entry,
1991
+ owner,
1992
+ resolvePushConnIds: (accountId) => this.resolvePushConnIds(accountId),
1993
+ resolveRecentInboundConnIds: (accountId) => this.resolveRecentInboundConnIds(accountId),
1994
+ hasRecentInboundReachability: (accountId) => this.hasRecentInboundReachability(accountId),
1995
+ isRevalidatedAttemptedConn: (connId) => this.isRevalidatedAttemptedConn(entry, connId),
1996
+ selectOutboxFileTransferRouteCandidates,
1997
+ });
1998
+ const guard = resolveFileTransferGuard({
1999
+ gatewayContext: ctx,
2000
+ entry,
2001
+ owner,
2002
+ routeSelection: selection,
2003
+ mediaUrl: asString(meta.mediaUrl || '').trim(),
2004
+ });
2005
+ if (!guard.ok) {
2006
+ this.handleFileTransferPushGuardFailure({
2007
+ entry,
2008
+ guard,
2009
+ });
2010
+ return false;
2011
+ }
2012
+
2013
+ const { connIds, recentInboundReachable, routeReason, mediaUrl } = guard;
2014
+
2015
+ try {
2016
+ await this.pushFileTransferSuccessPath({
2017
+ entry,
2018
+ meta,
2019
+ owner,
2020
+ connIds,
2021
+ recentInboundReachable,
2022
+ routeReason,
2023
+ mediaUrl,
2024
+ });
2025
+ return true;
2026
+ } catch (error) {
2027
+ this.handleFileTransferPushFailure({
2028
+ entry,
2029
+ error,
2030
+ });
2031
+ return false;
2032
+ }
2033
+ }
2034
+
2035
+ private buildFileTransferOutboxEntry(params: {
2036
+ accountId: string;
2037
+ sessionKey: string;
2038
+ route: BncrRoute;
2039
+ mediaUrl: string;
2040
+ mediaLocalRoots?: readonly string[];
2041
+ text?: string;
2042
+ asVoice?: boolean;
2043
+ audioAsVoice?: boolean;
2044
+ kind?: 'tool' | 'block' | 'final';
2045
+ replyToId?: string;
2046
+ }): OutboxEntry {
2047
+ return buildFileTransferOutboxEntryFromRuntime({
2048
+ createMessageId: () => randomUUID(),
2049
+ now,
2050
+ normalizeAccountId,
2051
+ pushEvent: BNCR_PUSH_EVENT,
2052
+ accountId: params.accountId,
2053
+ sessionKey: params.sessionKey,
2054
+ route: params.route,
2055
+ mediaUrl: params.mediaUrl,
2056
+ mediaLocalRoots: params.mediaLocalRoots,
2057
+ text: asString(params.text || ''),
2058
+ asVoice: params.asVoice,
2059
+ audioAsVoice: params.audioAsVoice,
2060
+ kind: params.kind,
2061
+ replyToId: asString(params.replyToId || '').trim() || undefined,
2062
+ });
2063
+ }
2064
+
2065
+ private pruneMediaDedupeCache(sessionKey: string, currentTime = now()) {
2066
+ const sessionCache = this.recentMediaDedupeBySession.get(sessionKey);
2067
+ if (!sessionCache) return;
2068
+
2069
+ for (const [mediaUrl, entry] of sessionCache.entries()) {
2070
+ if (currentTime - entry.createdAt > 10_000) {
2071
+ sessionCache.delete(mediaUrl);
1557
2072
  }
2073
+ }
1558
2074
 
1559
- try {
1560
- const media = await this.transferMediaToBncrClient({
1561
- accountId: entry.accountId,
1562
- sessionKey: entry.sessionKey,
1563
- route: entry.route,
1564
- mediaUrl,
1565
- mediaLocalRoots: Array.isArray(meta.mediaLocalRoots)
1566
- ? meta.mediaLocalRoots.filter((v): v is string => typeof v === 'string')
1567
- : undefined,
1568
- });
1569
- const wantsVoice = meta.asVoice === true || meta.audioAsVoice === true;
1570
- const frame = buildBncrMediaOutboundFrame({
1571
- messageId: entry.messageId,
1572
- sessionKey: entry.sessionKey,
1573
- route: entry.route,
1574
- media,
1575
- mediaUrl,
1576
- mediaMsg: asString(meta.text || ''),
1577
- fileName: resolveOutboundFileName({
1578
- mediaUrl,
1579
- fileName: media.fileName,
1580
- mimeType: media.mimeType,
1581
- }),
1582
- hintedType: wantsVoice ? 'voice' : undefined,
2075
+ if (sessionCache.size === 0) {
2076
+ this.recentMediaDedupeBySession.delete(sessionKey);
2077
+ }
2078
+ }
2079
+
2080
+ private rememberRecentMediaSend(params: {
2081
+ sessionKey: string;
2082
+ mediaUrl: string;
2083
+ text: string;
2084
+ replyToId: string;
2085
+ createdAt?: number;
2086
+ }) {
2087
+ const sessionKey = asString(params.sessionKey || '').trim();
2088
+ const mediaUrl = asString(params.mediaUrl || '').trim();
2089
+ if (!sessionKey || !mediaUrl) return;
2090
+
2091
+ const createdAt = typeof params.createdAt === 'number' ? params.createdAt : now();
2092
+ this.pruneMediaDedupeCache(sessionKey, createdAt);
2093
+ let sessionCache = this.recentMediaDedupeBySession.get(sessionKey);
2094
+ if (!sessionCache) {
2095
+ sessionCache = new Map<string, MediaDedupeCacheEntry>();
2096
+ this.recentMediaDedupeBySession.set(sessionKey, sessionCache);
2097
+ }
2098
+ sessionCache.set(mediaUrl, {
2099
+ mediaUrl,
2100
+ text: normalizeMessageText(params.text),
2101
+ replyToId: normalizeReplyToId(params.replyToId),
2102
+ createdAt,
2103
+ });
2104
+ }
2105
+
2106
+ private tryBuildMediaDedupeFallback(params: {
2107
+ sessionKey: string;
2108
+ mediaUrl: string;
2109
+ text: string;
2110
+ replyToId: string;
2111
+ currentTime?: number;
2112
+ }): { text: string; reason: 'same-text-sent-checkmark' | 'text-changed-downgrade' } | null {
2113
+ const sessionKey = asString(params.sessionKey || '').trim();
2114
+ const mediaUrl = asString(params.mediaUrl || '').trim();
2115
+ if (!sessionKey || !mediaUrl) return null;
2116
+
2117
+ const currentTime = typeof params.currentTime === 'number' ? params.currentTime : now();
2118
+ this.pruneMediaDedupeCache(sessionKey, currentTime);
2119
+ const sessionCache = this.recentMediaDedupeBySession.get(sessionKey);
2120
+ const previous = sessionCache?.get(mediaUrl);
2121
+ if (!previous) return null;
2122
+ if (currentTime - previous.createdAt > 10_000) return null;
2123
+
2124
+ return buildMediaTextFallback({
2125
+ currentText: normalizeMessageText(params.text),
2126
+ previousText: previous.text,
2127
+ currentReplyToId: normalizeReplyToId(params.replyToId),
2128
+ previousReplyToId: previous.replyToId,
2129
+ });
2130
+ }
2131
+
2132
+ private buildFileTransferOutboundFrame(params: {
2133
+ entry: OutboxEntry;
2134
+ meta: Record<string, unknown>;
2135
+ media: { fileName?: string; mimeType?: string; path?: string; base64?: string; type?: string };
2136
+ mediaUrl: string;
2137
+ }) {
2138
+ const wantsVoice = params.meta.asVoice === true || params.meta.audioAsVoice === true;
2139
+ const messageKind =
2140
+ params.meta.messageKind === 'tool' ||
2141
+ params.meta.messageKind === 'block' ||
2142
+ params.meta.messageKind === 'final'
2143
+ ? params.meta.messageKind
2144
+ : undefined;
2145
+
2146
+ return buildBncrMediaOutboundFrame({
2147
+ messageId: params.entry.messageId,
2148
+ sessionKey: params.entry.sessionKey,
2149
+ route: params.entry.route,
2150
+ media: params.media,
2151
+ mediaUrl: params.mediaUrl,
2152
+ mediaMsg: asString(params.meta.text || ''),
2153
+ fileName: resolveOutboundFileName({
2154
+ mediaUrl: params.mediaUrl,
2155
+ fileName: params.media.fileName,
2156
+ mimeType: params.media.mimeType,
2157
+ }),
2158
+ hintedType: wantsVoice ? 'voice' : undefined,
2159
+ kind: messageKind,
2160
+ replyToId: normalizeReplyToId(params.meta.replyToId) || undefined,
2161
+ now: now(),
2162
+ });
2163
+ }
2164
+
2165
+ private buildTextOutboxEntry(params: {
2166
+ accountId: string;
2167
+ sessionKey: string;
2168
+ route: BncrRoute;
2169
+ text: string;
2170
+ kind?: 'tool' | 'block' | 'final';
2171
+ replyToId?: string;
2172
+ }): OutboxEntry {
2173
+ return buildTextOutboxEntryFromRuntime({
2174
+ createMessageId: () => randomUUID(),
2175
+ now,
2176
+ normalizeAccountId,
2177
+ normalizeReplyToId,
2178
+ accountId: params.accountId,
2179
+ sessionKey: params.sessionKey,
2180
+ route: params.route,
2181
+ text: params.text,
2182
+ kind: params.kind,
2183
+ replyToId: params.replyToId,
2184
+ });
2185
+ }
2186
+
2187
+ private async tryPushEntry(entry: OutboxEntry): Promise<boolean> {
2188
+ const meta = isPlainObject(entry.payload?._meta) ? entry.payload._meta : null;
2189
+ if (meta?.kind === 'file-transfer') {
2190
+ return this.tryPushFileTransferEntry(entry, meta);
2191
+ }
2192
+
2193
+ return this.tryPushTextEntry(entry);
2194
+ }
2195
+
2196
+ private pushTextSuccessPath(args: {
2197
+ entry: OutboxEntry;
2198
+ owner: ReturnType<BncrBridgeRuntime['resolveOutboxPushOwner']>;
2199
+ connIds: Iterable<string>;
2200
+ recentInboundReachable: boolean;
2201
+ routeReason: string;
2202
+ ownerConnId?: string;
2203
+ }) {
2204
+ this.gatewayContext!.broadcastToConnIds(
2205
+ BNCR_PUSH_EVENT,
2206
+ buildTextPushBroadcastPayload({
2207
+ payload: args.entry.payload,
2208
+ messageId: args.entry.messageId,
2209
+ }),
2210
+ args.connIds,
2211
+ );
2212
+ this.logOutboxRouteSelect(
2213
+ buildTextPushRouteSelectArgs({
2214
+ entry: args.entry,
2215
+ connIds: args.connIds,
2216
+ routeReason: args.routeReason,
2217
+ recentInboundReachable: args.recentInboundReachable,
2218
+ owner: args.owner,
2219
+ event: BNCR_PUSH_EVENT,
2220
+ }),
2221
+ );
2222
+ this.recordOutboxPushSuccess(
2223
+ buildTextPushSuccessArgs({
2224
+ entry: args.entry,
2225
+ connIds: args.connIds,
2226
+ ownerConnId: args.ownerConnId,
2227
+ ownerClientId: args.ownerConnId ? args.owner?.clientId : undefined,
2228
+ }),
2229
+ );
2230
+ this.logOutboxPushOkSummary(args.entry.messageId);
2231
+ this.logOutboxPushOk(
2232
+ buildTextPushOkArgs({
2233
+ entry: args.entry,
2234
+ connIds: args.connIds,
2235
+ recentInboundReachable: args.recentInboundReachable,
2236
+ event: BNCR_PUSH_EVENT,
2237
+ }),
2238
+ );
2239
+ }
2240
+
2241
+ private handleTextPushFailure(args: {
2242
+ entry: OutboxEntry;
2243
+ error: unknown;
2244
+ }) {
2245
+ this.recordOutboxPushFailure({
2246
+ entry: args.entry,
2247
+ error: args.error,
2248
+ fallbackError: 'push-error',
2249
+ });
2250
+ this.logOutboxPushFailureSummary(args.entry.messageId, args.entry.lastError);
2251
+ this.logOutboxPushFailure(buildTextPushFailureArgs({ entry: args.entry }));
2252
+ }
2253
+
2254
+ private async tryPushTextEntry(entry: OutboxEntry): Promise<boolean> {
2255
+ const ctx = this.gatewayContext;
2256
+ const owner = this.resolveOutboxPushOwner(entry.accountId);
2257
+ const selection = prepareTextPushRouteSelection({
2258
+ entry,
2259
+ owner,
2260
+ resolvePushConnIds: (accountId) => this.resolvePushConnIds(accountId),
2261
+ resolveRecentInboundConnIds: (accountId) => this.resolveRecentInboundConnIds(accountId),
2262
+ hasRecentInboundReachability: (accountId) => this.hasRecentInboundReachability(accountId),
2263
+ isRevalidatedAttemptedConn: (connId) => this.isRevalidatedAttemptedConn(entry, connId),
2264
+ selectOutboxRouteCandidates,
2265
+ });
2266
+ const guard = resolveTextPushGuard({
2267
+ gatewayContext: ctx,
2268
+ entry,
2269
+ routeSelection: selection,
2270
+ });
2271
+ if (!guard.ok) {
2272
+ this.logOutboxPushSkip({
2273
+ messageId: entry.messageId,
2274
+ accountId: entry.accountId,
2275
+ reason: guard.reason,
2276
+ recentInboundReachable:
2277
+ guard.reason === 'no-active-connection' ? guard.recentInboundReachable : undefined,
2278
+ });
2279
+ return false;
2280
+ }
2281
+
2282
+ const { connIds, recentInboundReachable, routeReason, ownerConnId } = guard;
2283
+
2284
+ try {
2285
+ this.pushTextSuccessPath({
2286
+ entry,
2287
+ owner,
2288
+ connIds,
2289
+ recentInboundReachable,
2290
+ routeReason,
2291
+ ownerConnId,
2292
+ });
2293
+ return true;
2294
+ } catch (error) {
2295
+ this.handleTextPushFailure({
2296
+ entry,
2297
+ error,
2298
+ });
2299
+ return false;
2300
+ }
2301
+ }
2302
+
2303
+ private logOutboxPushSkip(args: {
2304
+ messageId: string;
2305
+ accountId: string;
2306
+ kind?: 'file-transfer';
2307
+ reason: string;
2308
+ recentInboundReachable?: boolean;
2309
+ }) {
2310
+ this.logInfo(
2311
+ 'outbox',
2312
+ `push-skip ${JSON.stringify(buildOutboxPushSkipDebugInfo(args))}`,
2313
+ { debugOnly: true },
2314
+ );
2315
+ }
2316
+
2317
+ private logOutboxRouteSelect(args: {
2318
+ messageId: string;
2319
+ accountId: string;
2320
+ kind?: 'file-transfer';
2321
+ routeReason: string;
2322
+ connIds: Iterable<string>;
2323
+ ownerConnId: string;
2324
+ ownerClientId: string;
2325
+ recentInboundReachable: boolean;
2326
+ event: string;
2327
+ }) {
2328
+ this.logInfo(
2329
+ 'outbox',
2330
+ `route-select ${JSON.stringify(buildOutboxRouteSelectDebugInfo(args))}`,
2331
+ { debugOnly: true },
2332
+ );
2333
+ }
2334
+
2335
+ private logOutboxPushFailure(args: {
2336
+ messageId: string;
2337
+ accountId: string;
2338
+ retryCount: number;
2339
+ kind?: 'file-transfer';
2340
+ retryable?: boolean;
2341
+ lastError?: string;
2342
+ }) {
2343
+ this.logInfo(
2344
+ 'outbox',
2345
+ `push-fail ${JSON.stringify(buildPushFailureDebugInfo(args))}`,
2346
+ { debugOnly: true },
2347
+ );
2348
+ }
2349
+
2350
+ private logOutboxPushOkSummary(messageId: string) {
2351
+ this.logInfo('outbox push', `mid=${messageId}|q=${this.outbox.size}`);
2352
+ }
2353
+
2354
+ private logOutboxPushFailureSummary(messageId: string, lastError?: string) {
2355
+ this.logInfo('outbox push fail', `mid=${messageId}|q=${this.outbox.size}|err=${lastError}`);
2356
+ }
2357
+
2358
+ private logOutboxAckSummary(
2359
+ scope: 'outbox ack ok' | 'outbox ack retry' | 'outbox ack timeout' | 'outbox ack fatal',
2360
+ args: {
2361
+ messageId: string;
2362
+ connId?: string;
2363
+ clientId?: string;
2364
+ err?: string;
2365
+ },
2366
+ ) {
2367
+ const parts = [`mid=${args.messageId}`, `q=${this.outbox.size}`];
2368
+ if (args.connId) parts.push(`conn=${args.connId}`);
2369
+ if (args.clientId) parts.push(`client=${args.clientId}`);
2370
+ if (args.err) parts.push(`err=${args.err}`);
2371
+ this.logInfo(scope, parts.join('|'));
2372
+ }
2373
+
2374
+ private logOutboxAckWait(args: {
2375
+ entry: OutboxEntry;
2376
+ requireAck: boolean;
2377
+ ackResult: 'acked' | 'timeout';
2378
+ onlineNow: boolean;
2379
+ recentInboundReachable: boolean;
2380
+ }) {
2381
+ this.logInfo(
2382
+ 'outbox',
2383
+ `ack ${JSON.stringify(
2384
+ buildOutboxAckDebugInfo({
2385
+ messageId: args.entry.messageId,
2386
+ accountId: args.entry.accountId,
1583
2387
  kind:
1584
- meta.messageKind === 'tool' ||
1585
- meta.messageKind === 'block' ||
1586
- meta.messageKind === 'final'
1587
- ? meta.messageKind
2388
+ isPlainObject(args.entry.payload?._meta) && args.entry.payload?._meta?.kind === 'file-transfer'
2389
+ ? 'file-transfer'
1588
2390
  : undefined,
1589
- replyToId: asString(meta.replyToId || '').trim() || undefined,
1590
- now: now(),
1591
- });
2391
+ requireAck: args.requireAck,
2392
+ ackResult: args.ackResult,
2393
+ onlineNow: args.onlineNow,
2394
+ recentInboundReachable: args.recentInboundReachable,
2395
+ connIds: args.entry.lastPushConnId ? [args.entry.lastPushConnId] : [],
2396
+ ownerConnId: args.entry.lastPushConnId,
2397
+ ownerClientId: args.entry.lastPushClientId,
2398
+ event: BNCR_PUSH_EVENT,
2399
+ }),
2400
+ )}`,
2401
+ { debugOnly: true },
2402
+ );
2403
+ }
1592
2404
 
1593
- ctx.broadcastToConnIds(
1594
- BNCR_PUSH_EVENT,
1595
- {
1596
- ...frame,
1597
- idempotencyKey: entry.messageId,
1598
- },
1599
- connIds,
1600
- );
1601
- entry.lastPushAt = now();
1602
- entry.lastPushConnId =
1603
- owner?.connId || (connIds.size === 1 ? Array.from(connIds)[0] : undefined);
1604
- entry.lastPushClientId = owner?.clientId;
1605
- entry.lastError = undefined;
1606
- this.outbox.set(entry.messageId, entry);
1607
- this.lastOutboundByAccount.set(entry.accountId, entry.lastPushAt);
1608
- this.markActivity(entry.accountId, entry.lastPushAt);
1609
- this.scheduleSave();
1610
- this.logInfo(
1611
- 'outbox',
1612
- `push-ok ${JSON.stringify({
1613
- messageId: entry.messageId,
1614
- accountId: entry.accountId,
1615
- kind: 'file-transfer',
1616
- connIds: Array.from(connIds),
1617
- ownerConnId: entry.lastPushConnId || '',
1618
- ownerClientId: entry.lastPushClientId || '',
1619
- recentInboundReachable,
1620
- event: BNCR_PUSH_EVENT,
1621
- })}`,
1622
- { debugOnly: true },
1623
- );
1624
- return true;
1625
- } catch (error) {
1626
- entry.lastError = asString((error as any)?.message || error || 'file-transfer-error');
1627
- this.outbox.set(entry.messageId, entry);
1628
- this.scheduleSave();
1629
- this.logInfo(
1630
- 'outbox',
1631
- `push-fail ${JSON.stringify({
1632
- messageId: entry.messageId,
1633
- accountId: entry.accountId,
1634
- kind: 'file-transfer',
1635
- retryable: this.isRetryableFileTransferError(error),
1636
- error: entry.lastError,
1637
- })}`,
2405
+ private logOutboxAckReroute(args: {
2406
+ accountId: string;
2407
+ entry: OutboxEntry;
2408
+ requireAck: boolean;
2409
+ currentConnId: string;
2410
+ availableConnIds: string[];
2411
+ decision: ReturnType<typeof computeRetryRerouteDecision>;
2412
+ localNextDelay: number | null;
2413
+ }) {
2414
+ this.logOutboxAckSummary(
2415
+ args.requireAck ? 'outbox ack timeout' : 'outbox ack retry',
2416
+ {
2417
+ messageId: args.entry.messageId,
2418
+ connId: args.entry.lastPushConnId,
2419
+ clientId: args.entry.lastPushClientId,
2420
+ err: args.requireAck ? undefined : args.entry.lastError,
2421
+ },
2422
+ );
2423
+ this.logInfo(
2424
+ 'outbox',
2425
+ `retry-reroute ${JSON.stringify(
2426
+ buildRetryRerouteDebugInfo({
2427
+ messageId: args.entry.messageId,
2428
+ accountId: args.accountId,
2429
+ currentConnId: args.currentConnId,
2430
+ decision: args.decision,
2431
+ availableConnIds: args.availableConnIds,
2432
+ }),
2433
+ )}`,
2434
+ { debugOnly: true },
2435
+ );
2436
+
2437
+ this.logInfo(
2438
+ 'outbox',
2439
+ `schedule ${JSON.stringify(
2440
+ buildOutboxScheduleDebugInfo({
2441
+ bridgeId: this.bridgeId,
2442
+ accountId: args.accountId,
2443
+ messageId: args.entry.messageId,
2444
+ source: OUTBOUND_SCHEDULE_SOURCE.RETRY_REROUTE_WAIT,
2445
+ wait: computeOutboxRetryWait(args.decision.nextAttemptAt, now()),
2446
+ localNextDelay: args.localNextDelay,
2447
+ }),
2448
+ )}`,
2449
+ { debugOnly: true },
2450
+ );
2451
+ }
2452
+
2453
+ private respondAckResult(
2454
+ respond: GatewayRequestHandlerOptions['respond'],
2455
+ stale: boolean,
2456
+ result: { ok: true; movedToDeadLetter?: true; willRetry?: true },
2457
+ ) {
2458
+ respond(
2459
+ true,
2460
+ stale
2461
+ ? { ...result, stale: true, staleAccepted: true }
2462
+ : result,
2463
+ );
2464
+ }
2465
+
2466
+ private prepareAckHandling(args: {
2467
+ params: any;
2468
+ respond: GatewayRequestHandlerOptions['respond'];
2469
+ client: GatewayRequestHandlerOptions['client'];
2470
+ context: GatewayRequestHandlerOptions['context'];
2471
+ }): {
2472
+ accountId: string;
2473
+ connId: string;
2474
+ clientId?: string;
2475
+ messageId: string;
2476
+ entry: OutboxEntry;
2477
+ staleObserved: { stale: boolean };
2478
+ } | null {
2479
+ const { params, respond, client, context } = args;
2480
+ const accountId = normalizeAccountId(asString(params?.accountId || ''));
2481
+ const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2482
+ const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2483
+ const messageId = asString(params?.messageId || '').trim();
2484
+ const staleObserved = this.observeLease('ack', params ?? {});
2485
+
2486
+ this.logInfo(
2487
+ 'outbox',
2488
+ `ack ${JSON.stringify({
2489
+ accountId,
2490
+ messageId,
2491
+ ok: params?.ok !== false,
2492
+ fatal: params?.fatal === true,
2493
+ error: asString(params?.error || ''),
2494
+ stale: staleObserved.stale,
2495
+ })}`,
2496
+ { debugOnly: true },
2497
+ );
2498
+ if (!messageId) {
2499
+ respond(false, { error: 'messageId required' });
2500
+ return null;
2501
+ }
2502
+
2503
+ const entry = this.outbox.get(messageId);
2504
+ if (!entry) {
2505
+ respond(true, { ok: true, message: 'already-acked-or-missing', stale: staleObserved.stale });
2506
+ return null;
2507
+ }
2508
+
2509
+ if (entry.accountId !== accountId) {
2510
+ respond(false, { error: 'account mismatch' });
2511
+ return null;
2512
+ }
2513
+
2514
+ if (staleObserved.stale) {
2515
+ const sameConn = !!entry.lastPushConnId && entry.lastPushConnId === connId;
2516
+ const sameClient =
2517
+ !entry.lastPushConnId &&
2518
+ !!entry.lastPushClientId &&
2519
+ !!clientId &&
2520
+ entry.lastPushClientId === clientId;
2521
+ if (!(sameConn || sameClient)) {
2522
+ this.logWarn(
2523
+ 'stale',
2524
+ `ignore kind=ack accountId=${accountId} connId=${connId} clientId=${clientId || '-'} messageId=${messageId} reason=owner-mismatch lastPushConnId=${entry.lastPushConnId || '-'} lastPushClientId=${entry.lastPushClientId || '-'}`,
1638
2525
  { debugOnly: true },
1639
2526
  );
1640
- if (!this.isRetryableFileTransferError(error)) {
1641
- this.moveToDeadLetter(entry, entry.lastError || 'file-transfer-failed');
1642
- }
1643
- return false;
2527
+ respond(true, { ok: true, stale: true, ignored: true });
2528
+ return null;
1644
2529
  }
2530
+ } else {
2531
+ this.rememberGatewayContext(context);
2532
+ this.markSeen(accountId, connId, clientId);
2533
+ }
2534
+
2535
+ return {
2536
+ accountId,
2537
+ connId,
2538
+ clientId,
2539
+ messageId,
2540
+ entry,
2541
+ staleObserved,
2542
+ };
2543
+ }
2544
+
2545
+ private handleAckOk(args: {
2546
+ accountId: string;
2547
+ messageId: string;
2548
+ connId: string;
2549
+ clientId?: string;
2550
+ stale: boolean;
2551
+ }) {
2552
+ this.markOutboundCapability({
2553
+ accountId: args.accountId,
2554
+ connId: args.connId,
2555
+ clientId: args.clientId,
2556
+ outboundReady: true,
2557
+ preferredForOutbound: true,
2558
+ });
2559
+ this.lastAckOkByAccount.set(args.accountId, now());
2560
+ this.outbox.delete(args.messageId);
2561
+ this.scheduleSave();
2562
+ this.resolveMessageAck(args.messageId, 'acked');
2563
+ this.logOutboxAckSummary('outbox ack ok', {
2564
+ messageId: args.messageId,
2565
+ connId: args.connId,
2566
+ clientId: args.clientId,
2567
+ });
2568
+ }
2569
+
2570
+ private handleAckFatal(args: {
2571
+ entry: OutboxEntry;
2572
+ messageId: string;
2573
+ connId: string;
2574
+ clientId?: string;
2575
+ error: string;
2576
+ }) {
2577
+ this.moveToDeadLetter(args.entry, args.error);
2578
+ this.logOutboxAckSummary('outbox ack fatal', {
2579
+ messageId: args.messageId,
2580
+ connId: args.connId,
2581
+ clientId: args.clientId,
2582
+ err: args.error,
2583
+ });
2584
+ }
2585
+
2586
+ private handleAckRetry(args: {
2587
+ entry: OutboxEntry;
2588
+ messageId: string;
2589
+ connId: string;
2590
+ clientId?: string;
2591
+ error: string;
2592
+ }) {
2593
+ args.entry.nextAttemptAt = now() + 1_000;
2594
+ args.entry.lastError = args.error;
2595
+ this.outbox.set(args.messageId, args.entry);
2596
+ this.scheduleSave();
2597
+ this.logOutboxAckSummary('outbox ack retry', {
2598
+ messageId: args.messageId,
2599
+ connId: args.connId,
2600
+ clientId: args.clientId,
2601
+ err: args.entry.lastError,
2602
+ });
2603
+ }
2604
+
2605
+ private handleAckOutcome(args: {
2606
+ params: any;
2607
+ respond: GatewayRequestHandlerOptions['respond'];
2608
+ accountId: string;
2609
+ connId: string;
2610
+ clientId?: string;
2611
+ messageId: string;
2612
+ entry: OutboxEntry;
2613
+ staleObserved: { stale: boolean };
2614
+ }) {
2615
+ const { params, respond, accountId, connId, clientId, messageId, entry, staleObserved } = args;
2616
+ const ok = params?.ok !== false;
2617
+ const fatal = params?.fatal === true;
2618
+
2619
+ if (ok) {
2620
+ this.handleAckOk({
2621
+ accountId,
2622
+ messageId,
2623
+ connId,
2624
+ clientId,
2625
+ stale: staleObserved.stale,
2626
+ });
2627
+ this.respondAckResult(respond, staleObserved.stale, { ok: true });
2628
+ this.flushPushQueue({
2629
+ accountId,
2630
+ trigger: OUTBOUND_FLUSH_TRIGGER.ACK_OK,
2631
+ reason: OUTBOUND_FLUSH_REASON.MESSAGE_ACKED,
2632
+ });
2633
+ return;
2634
+ }
2635
+
2636
+ if (fatal) {
2637
+ const error = asString(params?.error || 'fatal-ack');
2638
+ this.handleAckFatal({
2639
+ entry,
2640
+ messageId,
2641
+ connId,
2642
+ clientId,
2643
+ error,
2644
+ });
2645
+ this.respondAckResult(respond, staleObserved.stale, {
2646
+ ok: true,
2647
+ movedToDeadLetter: true,
2648
+ });
2649
+ return;
1645
2650
  }
1646
2651
 
1647
- const ctx = this.gatewayContext;
1648
- if (!ctx) {
1649
- this.logInfo(
1650
- 'outbox',
1651
- `push-skip ${JSON.stringify({
1652
- messageId: entry.messageId,
1653
- accountId: entry.accountId,
1654
- reason: 'no-gateway-context',
1655
- })}`,
1656
- { debugOnly: true },
1657
- );
1658
- return false;
1659
- }
2652
+ this.handleAckRetry({
2653
+ entry,
2654
+ messageId,
2655
+ connId,
2656
+ clientId,
2657
+ error: asString(params?.error || 'retryable-ack'),
2658
+ });
1660
2659
 
1661
- const owner = this.resolveOutboxPushOwner(entry.accountId);
1662
- let connIds = owner?.connId
1663
- ? new Set([owner.connId])
1664
- : this.resolvePushConnIds(entry.accountId);
1665
- const recentInboundReachable = this.hasRecentInboundReachability(entry.accountId);
1666
- if (!connIds.size && recentInboundReachable) {
1667
- connIds = this.resolveRecentInboundConnIds(entry.accountId);
2660
+ this.respondAckResult(respond, staleObserved.stale, {
2661
+ ok: true,
2662
+ willRetry: true,
2663
+ });
2664
+ }
2665
+
2666
+ private prepareInboundAcceptance(args: {
2667
+ parsed: ReturnType<typeof parseBncrInboundParams>;
2668
+ canonicalAgentId: string;
2669
+ }):
2670
+ | {
2671
+ ok: true;
2672
+ accountId: string;
2673
+ sessionKey: string;
2674
+ inboundText: string;
2675
+ hasMedia: boolean;
2676
+ }
2677
+ | {
2678
+ ok: false;
2679
+ status: boolean;
2680
+ payload: ReturnType<typeof buildInboundResponsePayload>;
2681
+ } {
2682
+ const { parsed, canonicalAgentId } = args;
2683
+ const {
2684
+ accountId,
2685
+ platform,
2686
+ groupId,
2687
+ userId,
2688
+ sessionKeyfromroute,
2689
+ route,
2690
+ text,
2691
+ mediaBase64,
2692
+ mediaPathFromTransfer,
2693
+ msgId,
2694
+ peer,
2695
+ extracted,
2696
+ dedupKey,
2697
+ } = parsed;
2698
+
2699
+ if (!platform || (!userId && !groupId)) {
2700
+ return {
2701
+ ok: false,
2702
+ status: false,
2703
+ payload: buildInboundResponsePayload({ kind: 'invalid-peer' }),
2704
+ };
1668
2705
  }
1669
- if (!connIds.size) {
1670
- this.logInfo(
1671
- 'outbox',
1672
- `push-skip ${JSON.stringify({
1673
- messageId: entry.messageId,
1674
- accountId: entry.accountId,
1675
- reason: 'no-active-connection',
1676
- recentInboundReachable,
1677
- })}`,
1678
- { debugOnly: true },
1679
- );
1680
- return false;
2706
+ if (this.markInboundDedupSeen(dedupKey)) {
2707
+ return {
2708
+ ok: false,
2709
+ status: true,
2710
+ payload: buildInboundResponsePayload({
2711
+ kind: 'duplicated',
2712
+ accountId,
2713
+ msgId: msgId ?? null,
2714
+ }),
2715
+ };
1681
2716
  }
1682
2717
 
1683
- try {
1684
- const payload = {
1685
- ...entry.payload,
1686
- idempotencyKey: entry.messageId,
2718
+ const cfg = this.api.runtime.config.current();
2719
+ const gate = checkBncrMessageGate({
2720
+ parsed,
2721
+ cfg,
2722
+ account: resolveAccount(cfg, accountId),
2723
+ });
2724
+ if (!gate.allowed) {
2725
+ return {
2726
+ ok: false,
2727
+ status: true,
2728
+ payload: buildInboundResponsePayload({
2729
+ kind: 'gate-denied',
2730
+ accountId,
2731
+ msgId: msgId ?? null,
2732
+ reason: gate.reason,
2733
+ }),
1687
2734
  };
2735
+ }
1688
2736
 
1689
- ctx.broadcastToConnIds(BNCR_PUSH_EVENT, payload, connIds);
1690
- entry.lastPushAt = now();
1691
- entry.lastPushConnId =
1692
- owner?.connId || (connIds.size === 1 ? Array.from(connIds)[0] : undefined);
1693
- entry.lastPushClientId = owner?.clientId;
1694
- this.outbox.set(entry.messageId, entry);
1695
- this.logInfo(
1696
- 'outbox',
1697
- `push-ok ${JSON.stringify({
1698
- messageId: entry.messageId,
1699
- accountId: entry.accountId,
1700
- connIds: Array.from(connIds),
1701
- ownerConnId: entry.lastPushConnId || '',
1702
- ownerClientId: entry.lastPushClientId || '',
1703
- recentInboundReachable,
1704
- event: BNCR_PUSH_EVENT,
1705
- })}`,
1706
- { debugOnly: true },
1707
- );
1708
- this.lastOutboundByAccount.set(entry.accountId, entry.lastPushAt);
1709
- this.markActivity(entry.accountId, entry.lastPushAt);
1710
- this.scheduleSave();
1711
- return true;
1712
- } catch (error) {
1713
- entry.lastError = asString((error as any)?.message || error || 'push-error');
1714
- this.outbox.set(entry.messageId, entry);
1715
- this.logInfo(
1716
- 'outbox',
1717
- `push-fail ${JSON.stringify({
1718
- messageId: entry.messageId,
1719
- accountId: entry.accountId,
1720
- error: entry.lastError,
1721
- })}`,
1722
- { debugOnly: true },
1723
- );
1724
- return false;
2737
+ const { sessionKey, inboundText } = resolveInboundSessionContext({
2738
+ cfg,
2739
+ accountId,
2740
+ peer,
2741
+ route,
2742
+ sessionKeyFromRoute: sessionKeyfromroute,
2743
+ canonicalAgentId,
2744
+ taskKey: extracted.taskKey,
2745
+ text,
2746
+ extractedText: extracted.text,
2747
+ resolveAgentRoute: (params) => this.api.runtime.channel.routing.resolveAgentRoute(params),
2748
+ });
2749
+
2750
+ return {
2751
+ ok: true,
2752
+ accountId,
2753
+ sessionKey,
2754
+ inboundText,
2755
+ hasMedia: Boolean(mediaBase64 || mediaPathFromTransfer),
2756
+ };
2757
+ }
2758
+
2759
+ private refreshLiveConnectionState(args: {
2760
+ accountId: string;
2761
+ connId: string;
2762
+ clientId?: string;
2763
+ outboundReady: boolean;
2764
+ preferredForOutbound: boolean;
2765
+ inboundOnly: boolean;
2766
+ context: GatewayRequestHandlerOptions['context'];
2767
+ }) {
2768
+ const { accountId, connId, clientId, outboundReady, preferredForOutbound, inboundOnly, context } = args;
2769
+ this.refreshAcceptedFileTransferLiveState({
2770
+ accountId,
2771
+ connId,
2772
+ clientId,
2773
+ context,
2774
+ });
2775
+ this.markOutboundCapability({
2776
+ accountId,
2777
+ connId,
2778
+ clientId,
2779
+ outboundReady,
2780
+ preferredForOutbound,
2781
+ inboundOnly,
2782
+ });
2783
+ }
2784
+
2785
+ private refreshAcceptedFileTransferLiveState(args: {
2786
+ accountId: string;
2787
+ connId: string;
2788
+ clientId?: string;
2789
+ context: GatewayRequestHandlerOptions['context'];
2790
+ }) {
2791
+ const { accountId, connId, clientId, context } = args;
2792
+ this.rememberGatewayContext(context);
2793
+ this.markSeen(accountId, connId, clientId);
2794
+ this.markActivity(accountId);
2795
+ }
2796
+
2797
+ private logOutboxPushOk(args: {
2798
+ messageId: string;
2799
+ accountId: string;
2800
+ kind?: 'file-transfer';
2801
+ connIds: Iterable<string>;
2802
+ ownerConnId: string;
2803
+ ownerClientId: string;
2804
+ recentInboundReachable: boolean;
2805
+ event: string;
2806
+ }) {
2807
+ this.logInfo(
2808
+ 'outbox',
2809
+ `push ${JSON.stringify(buildOutboxPushOkDebugInfo(args))}`,
2810
+ { debugOnly: true },
2811
+ );
2812
+ }
2813
+
2814
+ private recordOutboxPrePushFailure(args: {
2815
+ entry: OutboxEntry;
2816
+ lastError: string;
2817
+ }) {
2818
+ args.entry.lastError = args.lastError;
2819
+ this.outbox.set(args.entry.messageId, args.entry);
2820
+ }
2821
+
2822
+ private recordOutboxPushFailure(args: {
2823
+ entry: OutboxEntry;
2824
+ error: unknown;
2825
+ fallbackError: string;
2826
+ persist?: boolean;
2827
+ }) {
2828
+ args.entry.lastError = asString((args.error as any)?.message || args.error || args.fallbackError);
2829
+ this.outbox.set(args.entry.messageId, args.entry);
2830
+ if (args.persist) this.scheduleSave();
2831
+ }
2832
+
2833
+ private recordOutboxPushSuccess(args: {
2834
+ entry: OutboxEntry;
2835
+ connIds: Iterable<string>;
2836
+ ownerConnId?: string;
2837
+ ownerClientId?: string;
2838
+ clearLastError?: boolean;
2839
+ }) {
2840
+ const connIds = Array.from(args.connIds);
2841
+ args.entry.lastPushAt = now();
2842
+ args.entry.lastPushConnId =
2843
+ args.ownerConnId || (connIds.length === 1 ? connIds[0] : undefined);
2844
+ args.entry.lastPushClientId = args.ownerClientId;
2845
+ if (!Array.isArray(args.entry.routeAttemptConnIds)) args.entry.routeAttemptConnIds = [];
2846
+ if (
2847
+ args.entry.lastPushConnId &&
2848
+ !args.entry.routeAttemptConnIds.includes(args.entry.lastPushConnId)
2849
+ ) {
2850
+ args.entry.routeAttemptConnIds.push(args.entry.lastPushConnId);
1725
2851
  }
2852
+ if (args.clearLastError) args.entry.lastError = undefined;
2853
+ this.outbox.set(args.entry.messageId, args.entry);
2854
+ this.lastOutboundByAccount.set(args.entry.accountId, args.entry.lastPushAt);
2855
+ this.markActivity(args.entry.accountId, args.entry.lastPushAt);
2856
+ this.scheduleSave();
1726
2857
  }
1727
2858
 
1728
2859
  private schedulePushDrain(delayMs = 0) {
2860
+ // Structure note (drain scheduler):
2861
+ // This is the single-timer gate for outbound retry scheduling. It intentionally coalesces
2862
+ // multiple nudges into one pending timer and delegates all actual decision-making to
2863
+ // flushPushQueue. If extracted later, preserve the current "one pending timer per bridge"
2864
+ // behavior so retry cadence and burst control do not change accidentally.
1729
2865
  if (this.pushTimer) return;
1730
- const delay = Math.max(0, Math.min(Number(delayMs || 0), 30_000));
2866
+ const delay = clampOutboxDrainDelay(delayMs);
2867
+ this.logInfo(
2868
+ 'outbox',
2869
+ `schedule ${JSON.stringify(
2870
+ buildOutboxScheduleDebugInfo({
2871
+ bridgeId: this.bridgeId,
2872
+ source: OUTBOUND_SCHEDULE_SOURCE.SCHEDULE_PUSH_DRAIN,
2873
+ wait: delay,
2874
+ }),
2875
+ )}`,
2876
+ { debugOnly: true },
2877
+ );
1731
2878
  this.pushTimer = setTimeout(() => {
1732
2879
  this.pushTimer = null;
1733
- void this.flushPushQueue();
2880
+ void this.flushPushQueue({
2881
+ trigger: OUTBOUND_FLUSH_TRIGGER.TIMER,
2882
+ reason: OUTBOUND_FLUSH_REASON.SCHEDULED_DRAIN,
2883
+ });
1734
2884
  }, delay);
1735
2885
  }
1736
2886
 
@@ -1770,23 +2920,56 @@ class BncrBridgeRuntime {
1770
2920
  };
1771
2921
  }
1772
2922
 
1773
- private async flushPushQueue(accountId?: string): Promise<void> {
1774
- const filterAcc = accountId ? normalizeAccountId(accountId) : null;
1775
- const targetAccounts = filterAcc
1776
- ? [filterAcc]
1777
- : Array.from(
1778
- new Set(
1779
- Array.from(this.outbox.values()).map((entry) => normalizeAccountId(entry.accountId)),
1780
- ),
1781
- );
2923
+ private async flushPushQueue(args?: {
2924
+ accountId?: string;
2925
+ trigger?: string;
2926
+ reason?: string;
2927
+ }): Promise<void> {
2928
+ // Structure guide for future safe extraction:
2929
+ // - pre-check: choose target accounts, skip accounts already draining, emit flush context logs
2930
+ // - tryPush: pick one due entry per account and attempt actual outbound delivery
2931
+ // - ack wait: wait for message ack when policy requires it, then decide whether queue can advance
2932
+ // - degrade: mark timed-out / unconfirmed outbound capability on the attempted owner connection
2933
+ // - reroute: avoid the timed-out route, optionally revalidate a previously-attempted conn, then retry once
2934
+ // - retry scheduling: keep the entry in outbox, compute backoff / nextAttemptAt, and schedule next drain
2935
+ // - dead letter: after max retries, move the entry out of the active outbox into deadLetter
2936
+ //
2937
+ // Wake-source note:
2938
+ // flushPushQueue is entered from several distinct wake sources with different meanings:
2939
+ // - enqueue/manual: a new outbound entry was added and may be due immediately
2940
+ // - timer/scheduled-drain: retry scheduling says a previously-deferred entry is now worth retrying
2941
+ // - connect/ws-online: a transport became available again
2942
+ // - ack-ok/message-acked: one completed message may let the queue advance to the next
2943
+ // - activity/activity-heartbeat: capability/liveness was refreshed
2944
+ // - inbound/inbound-accepted: inbound traffic provided a fresh reachability signal
2945
+ // Keep these wake reasons explicit in future refactors; they are observability and behavior boundaries,
2946
+ // not just log decoration.
2947
+ //
2948
+ // Refactor boundary note:
2949
+ // flushPushQueue is the core outbound state machine. It currently couples queue selection,
2950
+ // route choice, ack policy, degrade/failover, retry timing, and dead-letter transitions.
2951
+ // Future extraction should preserve these semantics first; do not split behavior and routing in
2952
+ // the same change unless tests already lock the full lifecycle.
2953
+ const filterAcc = args?.accountId ? normalizeAccountId(args.accountId) : null;
2954
+ const trigger = asString(args?.trigger || '').trim() || 'manual';
2955
+ const reason = asString(args?.reason || '').trim() || undefined;
2956
+ const targetAccounts = selectOutboxTargetAccounts({
2957
+ accountId: filterAcc,
2958
+ outboxEntries: this.outbox.values(),
2959
+ normalizeAccountId,
2960
+ });
1782
2961
  this.logInfo(
1783
2962
  'outbox',
1784
- `flush ${JSON.stringify({
1785
- bridge: this.bridgeId,
1786
- accountId: filterAcc,
1787
- targetAccounts,
1788
- outboxSize: this.outbox.size,
1789
- })}`,
2963
+ `flush ${JSON.stringify(
2964
+ buildFlushDebugInfo({
2965
+ bridgeId: this.bridgeId,
2966
+ accountId: filterAcc,
2967
+ targetAccounts,
2968
+ outboxSize: this.outbox.size,
2969
+ trigger,
2970
+ reason,
2971
+ }),
2972
+ )}`,
1790
2973
  { debugOnly: true },
1791
2974
  );
1792
2975
 
@@ -1798,18 +2981,15 @@ class BncrBridgeRuntime {
1798
2981
  const recentInboundReachable = this.hasRecentInboundReachability(acc);
1799
2982
  this.logInfo(
1800
2983
  'outbox',
1801
- `online ${JSON.stringify({
1802
- bridge: this.bridgeId,
1803
- accountId: acc,
1804
- online,
1805
- recentInboundReachable,
1806
- connections: Array.from(this.connections.values()).map((c) => ({
1807
- accountId: c.accountId,
1808
- connId: c.connId,
1809
- clientId: c.clientId,
1810
- lastSeenAt: c.lastSeenAt,
1811
- })),
1812
- })}`,
2984
+ `online ${JSON.stringify(
2985
+ buildOutboxOnlineDebugInfo({
2986
+ bridgeId: this.bridgeId,
2987
+ accountId: acc,
2988
+ online,
2989
+ recentInboundReachable,
2990
+ connections: this.connections.values(),
2991
+ }),
2992
+ )}`,
1813
2993
  { debugOnly: true },
1814
2994
  );
1815
2995
  this.pushDrainRunningAccounts.add(acc);
@@ -1818,20 +2998,38 @@ class BncrBridgeRuntime {
1818
2998
 
1819
2999
  while (true) {
1820
3000
  const t = now();
1821
- const entries = Array.from(this.outbox.values())
1822
- .filter((entry) => normalizeAccountId(entry.accountId) === acc)
1823
- .sort((a, b) => a.createdAt - b.createdAt);
3001
+ const entries = listAccountOutboxEntries({
3002
+ accountId: acc,
3003
+ outboxEntries: this.outbox.values(),
3004
+ normalizeAccountId,
3005
+ });
1824
3006
 
1825
3007
  if (!entries.length) break;
1826
3008
 
1827
- const entry = entries.find((item) => item.nextAttemptAt <= t);
3009
+ const entry = findDueOutboxEntry(entries, t);
1828
3010
  if (!entry) {
1829
- const wait = Math.max(0, entries[0].nextAttemptAt - t);
1830
- localNextDelay = localNextDelay == null ? wait : Math.min(localNextDelay, wait);
3011
+ const wait = computeNextOutboxDelay(entries, t);
3012
+ if (wait != null) {
3013
+ localNextDelay = updateMinOutboxDelay(localNextDelay, wait);
3014
+ this.logInfo(
3015
+ 'outbox',
3016
+ `schedule ${JSON.stringify(
3017
+ buildOutboxScheduleDebugInfo({
3018
+ bridgeId: this.bridgeId,
3019
+ accountId: acc,
3020
+ source: OUTBOUND_SCHEDULE_SOURCE.ACCOUNT_NO_DUE_ENTRY,
3021
+ wait,
3022
+ localNextDelay,
3023
+ }),
3024
+ )}`,
3025
+ { debugOnly: true },
3026
+ );
3027
+ }
1831
3028
  break;
1832
3029
  }
1833
3030
 
1834
- const onlineNow = this.isOnline(acc) || this.hasRecentInboundReachability(acc);
3031
+ const onlineNow = this.isOnline(acc);
3032
+ const recentInboundReachable = this.hasRecentInboundReachability(acc);
1835
3033
  const pushed = await this.tryPushEntry(entry);
1836
3034
  if (pushed) {
1837
3035
  const requireAck = this.isOutboundAckRequired(acc);
@@ -1840,17 +3038,13 @@ class BncrBridgeRuntime {
1840
3038
  ackResult = await this.waitForMessageAck(entry.messageId, PUSH_ACK_TIMEOUT_MS);
1841
3039
  }
1842
3040
 
1843
- this.logInfo(
1844
- 'outbox',
1845
- `ack ${JSON.stringify({
1846
- messageId: entry.messageId,
1847
- accountId: entry.accountId,
1848
- requireAck,
1849
- ackResult,
1850
- onlineNow,
1851
- })}`,
1852
- { debugOnly: true },
1853
- );
3041
+ this.logOutboxAckWait({
3042
+ entry,
3043
+ requireAck,
3044
+ ackResult,
3045
+ onlineNow,
3046
+ recentInboundReachable,
3047
+ });
1854
3048
 
1855
3049
  if (!this.outbox.has(entry.messageId)) {
1856
3050
  await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
@@ -1862,22 +3056,74 @@ class BncrBridgeRuntime {
1862
3056
  continue;
1863
3057
  }
1864
3058
 
1865
- entry.retryCount += 1;
1866
- entry.lastAttemptAt = now();
1867
- if (entry.retryCount > MAX_RETRY) {
1868
- this.moveToDeadLetter(
1869
- entry,
1870
- entry.lastError || (requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed'),
3059
+ if (entry.lastPushConnId || entry.lastPushClientId) {
3060
+ this.degradeOutboundCapability({
3061
+ accountId: acc,
3062
+ connId: entry.lastPushConnId || undefined,
3063
+ clientId: entry.lastPushClientId || undefined,
3064
+ reason: requireAck
3065
+ ? OUTBOUND_DEGRADE_REASON.ACK_TIMEOUT
3066
+ : OUTBOUND_DEGRADE_REASON.PUSH_UNCONFIRMED,
3067
+ });
3068
+ }
3069
+
3070
+ const attemptedConnIds = Array.isArray(entry.routeAttemptConnIds)
3071
+ ? entry.routeAttemptConnIds.filter((v): v is string => typeof v === 'string' && !!v)
3072
+ : [];
3073
+ const currentConnId = asString(entry.lastPushConnId || '').trim();
3074
+ const availableConnIds = Array.from(this.resolvePushConnIds(acc));
3075
+ const decision = computeRetryRerouteDecision(
3076
+ {
3077
+ nowMs: now(),
3078
+ maxRetry: MAX_RETRY,
3079
+ requireAck,
3080
+ currentRetryCount: entry.retryCount,
3081
+ currentRouteAttemptRound: Number(entry.routeAttemptRound || 0),
3082
+ currentFastReroutePending: entry.fastReroutePending === true,
3083
+ lastError: entry.lastError,
3084
+ currentConnId: currentConnId || undefined,
3085
+ attemptedConnIds,
3086
+ availableConnIds,
3087
+ },
3088
+ { backoffMs },
3089
+ );
3090
+
3091
+ if (decision.kind === 'dead-letter') {
3092
+ this.logInfo(
3093
+ 'outbox ack fatal',
3094
+ `mid=${entry.messageId}|q=${this.outbox.size}|err=${decision.terminalReason}`,
1871
3095
  );
3096
+ this.moveToDeadLetter(entry, decision.terminalReason);
1872
3097
  continue;
1873
3098
  }
1874
- entry.nextAttemptAt = now() + backoffMs(entry.retryCount);
1875
- entry.lastError = requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed';
3099
+
3100
+ entry.routeAttemptConnIds = decision.attemptedConnIds;
3101
+ entry.fastReroutePending = decision.fastReroutePending;
3102
+ entry.retryCount = decision.nextRetryCount;
3103
+ entry.lastAttemptAt = decision.lastAttemptAt;
3104
+ entry.nextAttemptAt = decision.nextAttemptAt;
3105
+ entry.lastError = decision.lastError;
3106
+ entry.routeAttemptRound = decision.routeAttemptRound;
1876
3107
  this.outbox.set(entry.messageId, entry);
1877
3108
  this.scheduleSave();
1878
-
1879
- const wait = Math.max(0, entry.nextAttemptAt - now());
1880
- localNextDelay = localNextDelay == null ? wait : Math.min(localNextDelay, wait);
3109
+ if (requireAck) {
3110
+ this.lastAckTimeoutByAccount.set(acc, now());
3111
+ this.ackTimeoutCountByAccount.set(
3112
+ acc,
3113
+ this.getCounter(this.ackTimeoutCountByAccount, acc) + 1,
3114
+ );
3115
+ }
3116
+ const wait = computeOutboxRetryWait(decision.nextAttemptAt, now());
3117
+ localNextDelay = updateMinOutboxDelay(localNextDelay, wait);
3118
+ this.logOutboxAckReroute({
3119
+ accountId: acc,
3120
+ entry,
3121
+ requireAck,
3122
+ currentConnId,
3123
+ availableConnIds,
3124
+ decision,
3125
+ localNextDelay,
3126
+ });
1881
3127
  await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
1882
3128
  break;
1883
3129
  }
@@ -1887,34 +3133,82 @@ class BncrBridgeRuntime {
1887
3133
  continue;
1888
3134
  }
1889
3135
 
1890
- const nextAttempt = entry.retryCount + 1;
1891
- if (nextAttempt > MAX_RETRY) {
1892
- this.moveToDeadLetter(entry, entry.lastError || 'push-retry-limit');
3136
+ const decision = computePushFailureDecision(
3137
+ {
3138
+ nowMs: t,
3139
+ maxRetry: MAX_RETRY,
3140
+ currentRetryCount: entry.retryCount,
3141
+ lastError: entry.lastError,
3142
+ },
3143
+ { backoffMs },
3144
+ );
3145
+ if (decision.kind === 'dead-letter') {
3146
+ this.moveToDeadLetter(entry, decision.terminalReason);
1893
3147
  continue;
1894
3148
  }
1895
3149
 
1896
- entry.retryCount = nextAttempt;
1897
- entry.lastAttemptAt = t;
1898
- entry.nextAttemptAt = t + backoffMs(nextAttempt);
1899
- entry.lastError = entry.lastError || 'push-retry';
3150
+ entry.retryCount = decision.nextRetryCount;
3151
+ entry.lastAttemptAt = decision.lastAttemptAt;
3152
+ entry.nextAttemptAt = decision.nextAttemptAt;
3153
+ entry.lastError = decision.lastError;
1900
3154
  this.outbox.set(entry.messageId, entry);
1901
3155
  this.scheduleSave();
1902
3156
 
1903
- const wait = Math.max(0, entry.nextAttemptAt - t);
1904
- localNextDelay = localNextDelay == null ? wait : Math.min(localNextDelay, wait);
3157
+ const wait = computeOutboxRetryWait(decision.nextAttemptAt, t);
3158
+ localNextDelay = updateMinOutboxDelay(localNextDelay, wait);
3159
+ this.logInfo(
3160
+ 'outbox',
3161
+ `schedule ${JSON.stringify(
3162
+ buildOutboxScheduleDebugInfo({
3163
+ bridgeId: this.bridgeId,
3164
+ accountId: acc,
3165
+ messageId: entry.messageId,
3166
+ source: OUTBOUND_SCHEDULE_SOURCE.PUSH_FAIL_WAIT,
3167
+ wait,
3168
+ localNextDelay,
3169
+ }),
3170
+ )}`,
3171
+ { debugOnly: true },
3172
+ );
1905
3173
  break;
1906
3174
  }
1907
3175
 
1908
3176
  if (localNextDelay != null) {
1909
- globalNextDelay =
1910
- globalNextDelay == null ? localNextDelay : Math.min(globalNextDelay, localNextDelay);
3177
+ globalNextDelay = updateMinOutboxDelay(globalNextDelay, localNextDelay);
3178
+ this.logInfo(
3179
+ 'outbox',
3180
+ `schedule ${JSON.stringify(
3181
+ buildOutboxScheduleDebugInfo({
3182
+ bridgeId: this.bridgeId,
3183
+ accountId: acc,
3184
+ source: OUTBOUND_SCHEDULE_SOURCE.ACCOUNT_NEXT_DELAY_MERGE,
3185
+ localNextDelay,
3186
+ globalNextDelay,
3187
+ }),
3188
+ )}`,
3189
+ { debugOnly: true },
3190
+ );
1911
3191
  }
1912
3192
  } finally {
1913
3193
  this.pushDrainRunningAccounts.delete(acc);
1914
3194
  }
1915
3195
  }
1916
3196
 
1917
- if (globalNextDelay != null) this.schedulePushDrain(globalNextDelay);
3197
+ if (globalNextDelay != null) {
3198
+ this.logInfo(
3199
+ 'outbox',
3200
+ `schedule ${JSON.stringify(
3201
+ buildOutboxScheduleDebugInfo({
3202
+ bridgeId: this.bridgeId,
3203
+ source: OUTBOUND_SCHEDULE_SOURCE.FLUSH_NEXT_DRAIN,
3204
+ globalNextDelay,
3205
+ wait: globalNextDelay,
3206
+ }),
3207
+ )}`,
3208
+ { debugOnly: true },
3209
+ );
3210
+ this.schedulePushDrain(globalNextDelay);
3211
+ }
1918
3212
  }
1919
3213
 
1920
3214
  private async waitForMessageAck(messageId: string, waitMs: number): Promise<'acked' | 'timeout'> {
@@ -1994,27 +3288,47 @@ class BncrBridgeRuntime {
1994
3288
  const previousActiveKey = this.activeConnectionByAccount.get(acc) || null;
1995
3289
  const previousActiveConn = previousActiveKey ? this.connections.get(previousActiveKey) || null : null;
1996
3290
 
1997
- const nextConn: BncrConnection = {
3291
+ const nextConn = {
1998
3292
  accountId: acc,
1999
3293
  connId,
2000
3294
  clientId: asString(clientId || '').trim() || undefined,
2001
3295
  connectedAt: prev?.connectedAt || t,
2002
3296
  lastSeenAt: t,
3297
+ outboundReadyUntil: (prev as any)?.outboundReadyUntil,
3298
+ preferredForOutboundUntil: (prev as any)?.preferredForOutboundUntil,
3299
+ inboundOnly: (prev as any)?.inboundOnly,
3300
+ } as BncrConnection & {
3301
+ outboundReadyUntil?: number;
3302
+ preferredForOutboundUntil?: number;
3303
+ inboundOnly?: boolean;
2003
3304
  };
2004
3305
 
2005
- this.connections.set(key, nextConn);
2006
- this.logInfo(
2007
- 'connection',
2008
- `seen ${JSON.stringify({
2009
- bridge: this.bridgeId,
2010
- accountId: acc,
2011
- connId,
2012
- clientId: nextConn.clientId,
2013
- connectedAt: nextConn.connectedAt,
2014
- lastSeenAt: nextConn.lastSeenAt,
2015
- })}`,
2016
- { debugOnly: true },
2017
- );
3306
+ this.connections.set(key, nextConn as BncrConnection);
3307
+ const connectionSeenPayload = {
3308
+ bridge: this.bridgeId,
3309
+ accountId: acc,
3310
+ connId,
3311
+ clientId: nextConn.clientId,
3312
+ connectedAt: nextConn.connectedAt,
3313
+ lastSeenAt: nextConn.lastSeenAt,
3314
+ outboundReadyUntil: nextConn.outboundReadyUntil || null,
3315
+ preferredForOutboundUntil: nextConn.preferredForOutboundUntil || null,
3316
+ inboundOnly: nextConn.inboundOnly === true,
3317
+ };
3318
+ const connectionSeenSig = JSON.stringify({
3319
+ bridge: this.bridgeId,
3320
+ accountId: acc,
3321
+ connId,
3322
+ clientId: nextConn.clientId || null,
3323
+ inboundOnly: nextConn.inboundOnly === true,
3324
+ outboundReadyActive: Number(nextConn.outboundReadyUntil || 0) > t,
3325
+ preferredForOutboundActive: Number(nextConn.preferredForOutboundUntil || 0) > t,
3326
+ });
3327
+ this.logInfoDedupJson('connection', 'seen', connectionSeenPayload, {
3328
+ key: `connection-seen:${acc}:${nextConn.clientId || connId}`,
3329
+ sig: connectionSeenSig,
3330
+ debugOnly: true,
3331
+ });
2018
3332
 
2019
3333
  const current = this.activeConnectionByAccount.get(acc);
2020
3334
  if (!current) {
@@ -2036,6 +3350,9 @@ class BncrBridgeRuntime {
2036
3350
  clientId: c.clientId,
2037
3351
  connectedAt: c.connectedAt,
2038
3352
  lastSeenAt: c.lastSeenAt,
3353
+ outboundReadyUntil: (c as any).outboundReadyUntil || null,
3354
+ preferredForOutboundUntil: (c as any).preferredForOutboundUntil || null,
3355
+ inboundOnly: (c as any).inboundOnly === true,
2039
3356
  })),
2040
3357
  })}`,
2041
3358
  { debugOnly: true },
@@ -2043,39 +3360,173 @@ class BncrBridgeRuntime {
2043
3360
  return;
2044
3361
  }
2045
3362
 
2046
- const curConn = this.connections.get(current);
2047
- if (
2048
- !curConn ||
2049
- t - curConn.lastSeenAt > CONNECT_TTL_MS ||
2050
- nextConn.connectedAt >= curConn.connectedAt
2051
- ) {
2052
- this.activeConnectionByAccount.set(acc, key);
3363
+ const curConn = this.connections.get(current);
3364
+ if (!curConn || t - curConn.lastSeenAt > CONNECT_TTL_MS) {
3365
+ this.activeConnectionByAccount.set(acc, key);
3366
+ this.logInfo(
3367
+ 'connection',
3368
+ `seen:promote ${JSON.stringify({
3369
+ bridge: this.bridgeId,
3370
+ accountId: acc,
3371
+ reason: !curConn ? 'current-missing' : 'current-stale',
3372
+ previousActiveKey,
3373
+ previousActiveConn,
3374
+ nextActiveKey: key,
3375
+ nextActiveConn: nextConn,
3376
+ activeConnections: Array.from(this.connections.values())
3377
+ .filter((c) => c.accountId === acc)
3378
+ .map((c) => ({
3379
+ connId: c.connId,
3380
+ clientId: c.clientId,
3381
+ connectedAt: c.connectedAt,
3382
+ lastSeenAt: c.lastSeenAt,
3383
+ outboundReadyUntil: (c as any).outboundReadyUntil || null,
3384
+ preferredForOutboundUntil: (c as any).preferredForOutboundUntil || null,
3385
+ inboundOnly: (c as any).inboundOnly === true,
3386
+ })),
3387
+ })}`,
3388
+ { debugOnly: true },
3389
+ );
3390
+ }
3391
+ }
3392
+
3393
+ private markOutboundCapability(args: {
3394
+ accountId: string;
3395
+ connId: string;
3396
+ clientId?: string;
3397
+ outboundReady?: boolean;
3398
+ preferredForOutbound?: boolean;
3399
+ inboundOnly?: boolean;
3400
+ at?: number;
3401
+ }) {
3402
+ const acc = normalizeAccountId(args.accountId);
3403
+ const key = this.connectionKey(acc, args.clientId);
3404
+ const t = Number(args.at || now());
3405
+ const current = this.connections.get(key) as BncrConnection | undefined;
3406
+ if (!current || current.connId !== args.connId) return;
3407
+
3408
+ const next = applyOutboundCapability({
3409
+ connection: current,
3410
+ at: t,
3411
+ outboundReadyTtlMs: OUTBOUND_READY_TTL_MS,
3412
+ preferredOutboundTtlMs: PREFERRED_OUTBOUND_TTL_MS,
3413
+ outboundReady: args.outboundReady,
3414
+ preferredForOutbound: args.preferredForOutbound,
3415
+ inboundOnly: args.inboundOnly,
3416
+ });
3417
+
3418
+ this.connections.set(key, next as BncrConnection);
3419
+ const snapshot = buildCapabilitySnapshot(next);
3420
+ const connectionCapabilityPayload = {
3421
+ bridge: this.bridgeId,
3422
+ accountId: acc,
3423
+ connId: next.connId,
3424
+ clientId: next.clientId,
3425
+ outboundReady: args.outboundReady === true,
3426
+ preferredForOutbound: args.preferredForOutbound === true,
3427
+ inboundOnly: snapshot.inboundOnly,
3428
+ outboundReadyUntil: snapshot.outboundReadyUntil,
3429
+ preferredForOutboundUntil: snapshot.preferredForOutboundUntil,
3430
+ };
3431
+ const connectionCapabilitySig = JSON.stringify({
3432
+ bridge: this.bridgeId,
3433
+ accountId: acc,
3434
+ connId: next.connId,
3435
+ clientId: next.clientId || null,
3436
+ outboundReady: args.outboundReady === true,
3437
+ preferredForOutbound: args.preferredForOutbound === true,
3438
+ inboundOnly: snapshot.inboundOnly,
3439
+ outboundReadyActive: Number(snapshot.outboundReadyUntil || 0) > t,
3440
+ preferredForOutboundActive: Number(snapshot.preferredForOutboundUntil || 0) > t,
3441
+ });
3442
+ this.logInfoDedupJson('connection', 'capability', connectionCapabilityPayload, {
3443
+ key: `connection-capability:${acc}:${next.clientId || next.connId}`,
3444
+ sig: connectionCapabilitySig,
3445
+ debugOnly: true,
3446
+ });
3447
+ }
3448
+
3449
+ private hasAlternativeLiveConnection(
3450
+ accountId: string,
3451
+ currentConnId?: string,
3452
+ currentClientId?: string,
3453
+ ): boolean {
3454
+ const acc = normalizeAccountId(accountId);
3455
+ return hasAlternativeLiveConnectionFromRuntime({
3456
+ accountId: acc,
3457
+ now: now(),
3458
+ connectTtlMs: CONNECT_TTL_MS,
3459
+ currentConnId,
3460
+ currentClientId,
3461
+ connections: this.connections.values(),
3462
+ });
3463
+ }
3464
+
3465
+ private degradeOutboundCapability(args: {
3466
+ accountId: string;
3467
+ connId?: string;
3468
+ clientId?: string;
3469
+ reason: string;
3470
+ at?: number;
3471
+ }) {
3472
+ const acc = normalizeAccountId(args.accountId);
3473
+ const t = Number(args.at || now());
3474
+ const hasAlternativeLiveConnection = this.hasAlternativeLiveConnection(
3475
+ acc,
3476
+ args.connId,
3477
+ args.clientId,
3478
+ );
3479
+ const currentKey = this.activeConnectionByAccount.get(acc) || null;
3480
+ const matched = findCapabilityConnection({
3481
+ accountId: acc,
3482
+ connId: args.connId,
3483
+ clientId: args.clientId,
3484
+ connections: this.connections.entries(),
3485
+ });
3486
+
3487
+ if (!matched) return;
3488
+
3489
+ const before = buildCapabilitySnapshot(matched.connection);
3490
+
3491
+ if (!hasAlternativeLiveConnection) {
2053
3492
  this.logInfo(
2054
3493
  'connection',
2055
- `seen:promote ${JSON.stringify({
3494
+ `outbound-degrade skip ${JSON.stringify({
2056
3495
  bridge: this.bridgeId,
2057
3496
  accountId: acc,
2058
- reason: !curConn
2059
- ? 'current-missing'
2060
- : t - curConn.lastSeenAt > CONNECT_TTL_MS
2061
- ? 'current-stale'
2062
- : 'newer-or-equal-connectedAt',
2063
- previousActiveKey,
2064
- previousActiveConn,
2065
- nextActiveKey: key,
2066
- nextActiveConn: nextConn,
2067
- activeConnections: Array.from(this.connections.values())
2068
- .filter((c) => c.accountId === acc)
2069
- .map((c) => ({
2070
- connId: c.connId,
2071
- clientId: c.clientId,
2072
- connectedAt: c.connectedAt,
2073
- lastSeenAt: c.lastSeenAt,
2074
- })),
3497
+ connId: matched.connection.connId,
3498
+ clientId: matched.connection.clientId,
3499
+ reason: args.reason,
3500
+ at: t,
3501
+ currentActiveKey: currentKey,
3502
+ degradedKey: matched.key,
3503
+ skipReason: 'no-alternative-live-connection',
3504
+ before,
2075
3505
  })}`,
2076
3506
  { debugOnly: true },
2077
3507
  );
3508
+ return;
2078
3509
  }
3510
+
3511
+ const next = clearOutboundCapability(matched.connection);
3512
+ this.connections.set(matched.key, next as BncrConnection);
3513
+
3514
+ this.logInfo(
3515
+ 'connection',
3516
+ `outbound-degrade ${JSON.stringify({
3517
+ bridge: this.bridgeId,
3518
+ accountId: acc,
3519
+ connId: next.connId,
3520
+ clientId: next.clientId,
3521
+ reason: args.reason,
3522
+ at: t,
3523
+ currentActiveKey: currentKey,
3524
+ degradedKey: matched.key,
3525
+ before,
3526
+ after: buildCapabilitySnapshot(next),
3527
+ })}`,
3528
+ { debugOnly: true },
3529
+ );
2079
3530
  }
2080
3531
 
2081
3532
  private isOnline(accountId: string): boolean {
@@ -2281,7 +3732,7 @@ class BncrBridgeRuntime {
2281
3732
  const timer = setTimeout(() => {
2282
3733
  this.fileAckWaiters.delete(key);
2283
3734
  this.logWarn(
2284
- 'file-ack-timeout',
3735
+ OUTBOUND_TERMINAL_REASON.FILE_ACK_TIMEOUT,
2285
3736
  JSON.stringify({
2286
3737
  bridge: this.bridgeId,
2287
3738
  transferId,
@@ -2423,80 +3874,64 @@ class BncrBridgeRuntime {
2423
3874
  return { path: finalPath, fileSha256: sha };
2424
3875
  }
2425
3876
 
2426
- private buildStatusMeta(accountId: string) {
3877
+ private buildRuntimeQueueSnapshot(accountId: string) {
3878
+ const pending = Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length;
3879
+ const deadLetter = this.deadLetter.filter((v) => v.accountId === accountId).length;
3880
+ const sessionRoutesCount = Array.from(this.sessionRoutes.values()).filter(
3881
+ (v) => v.accountId === accountId,
3882
+ ).length;
3883
+ return {
3884
+ pending,
3885
+ deadLetter,
3886
+ sessionRoutesCount,
3887
+ invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(accountId),
3888
+ legacyAccountResidue: this.countLegacyAccountResidue(accountId),
3889
+ };
3890
+ }
3891
+
3892
+ private buildRuntimeEventCounters(accountId: string) {
3893
+ return {
3894
+ connectEvents: this.getCounter(this.connectEventsByAccount, accountId),
3895
+ inboundEvents: this.getCounter(this.inboundEventsByAccount, accountId),
3896
+ activityEvents: this.getCounter(this.activityEventsByAccount, accountId),
3897
+ ackEvents: this.getCounter(this.ackEventsByAccount, accountId),
3898
+ };
3899
+ }
3900
+
3901
+ private buildRuntimeActivitySnapshot(accountId: string) {
3902
+ return {
3903
+ activeConnections: this.activeConnectionCount(accountId),
3904
+ lastSession: this.lastSessionByAccount.get(accountId) || null,
3905
+ lastActivityAt: this.lastActivityByAccount.get(accountId) || null,
3906
+ lastInboundAt: this.lastInboundByAccount.get(accountId) || null,
3907
+ lastOutboundAt: this.lastOutboundByAccount.get(accountId) || null,
3908
+ };
3909
+ }
3910
+
3911
+ private buildRuntimeStatusInput(accountId: string, overrides: { running?: boolean } = {}) {
2427
3912
  const acc = normalizeAccountId(accountId);
2428
- return buildStatusMetaFromRuntime({
3913
+ return {
2429
3914
  accountId: acc,
2430
3915
  connected: this.isOnline(acc),
2431
- pending: Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length,
2432
- deadLetter: this.deadLetter.filter((v) => v.accountId === acc).length,
2433
- activeConnections: this.activeConnectionCount(acc),
2434
- connectEvents: this.getCounter(this.connectEventsByAccount, acc),
2435
- inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
2436
- activityEvents: this.getCounter(this.activityEventsByAccount, acc),
2437
- ackEvents: this.getCounter(this.ackEventsByAccount, acc),
3916
+ ...this.buildRuntimeQueueSnapshot(acc),
3917
+ ...this.buildRuntimeEventCounters(acc),
3918
+ ...this.buildRuntimeActivitySnapshot(acc),
2438
3919
  startedAt: this.startedAt,
2439
- lastSession: this.lastSessionByAccount.get(acc) || null,
2440
- lastActivityAt: this.lastActivityByAccount.get(acc) || null,
2441
- lastInboundAt: this.lastInboundByAccount.get(acc) || null,
2442
- lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
2443
- sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc)
2444
- .length,
2445
- invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
2446
- legacyAccountResidue: this.countLegacyAccountResidue(acc),
3920
+ running: overrides.running,
2447
3921
  channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
2448
- });
3922
+ };
3923
+ }
3924
+
3925
+ private buildStatusMeta(accountId: string) {
3926
+ return buildStatusMetaFromRuntime(this.buildRuntimeStatusInput(accountId));
2449
3927
  }
2450
3928
 
2451
3929
  getAccountRuntimeSnapshot(accountId: string) {
2452
- const acc = normalizeAccountId(accountId);
2453
- return buildAccountRuntimeSnapshot({
2454
- accountId: acc,
2455
- connected: this.isOnline(acc),
2456
- pending: Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length,
2457
- deadLetter: this.deadLetter.filter((v) => v.accountId === acc).length,
2458
- activeConnections: this.activeConnectionCount(acc),
2459
- connectEvents: this.getCounter(this.connectEventsByAccount, acc),
2460
- inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
2461
- activityEvents: this.getCounter(this.activityEventsByAccount, acc),
2462
- ackEvents: this.getCounter(this.ackEventsByAccount, acc),
2463
- startedAt: this.startedAt,
2464
- lastSession: this.lastSessionByAccount.get(acc) || null,
2465
- lastActivityAt: this.lastActivityByAccount.get(acc) || null,
2466
- lastInboundAt: this.lastInboundByAccount.get(acc) || null,
2467
- lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
2468
- sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc)
2469
- .length,
2470
- invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
2471
- legacyAccountResidue: this.countLegacyAccountResidue(acc),
2472
- running: true,
2473
- channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
2474
- });
3930
+ return buildAccountRuntimeSnapshot(this.buildRuntimeStatusInput(accountId, { running: true }));
2475
3931
  }
2476
3932
 
2477
3933
  private buildStatusHeadline(accountId: string): string {
2478
- const acc = normalizeAccountId(accountId);
2479
- return buildStatusHeadlineFromRuntime({
2480
- accountId: acc,
2481
- connected: this.isOnline(acc),
2482
- pending: Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length,
2483
- deadLetter: this.deadLetter.filter((v) => v.accountId === acc).length,
2484
- activeConnections: this.activeConnectionCount(acc),
2485
- connectEvents: this.getCounter(this.connectEventsByAccount, acc),
2486
- inboundEvents: this.getCounter(this.inboundEventsByAccount, acc),
2487
- activityEvents: this.getCounter(this.activityEventsByAccount, acc),
2488
- ackEvents: this.getCounter(this.ackEventsByAccount, acc),
2489
- startedAt: this.startedAt,
2490
- lastSession: this.lastSessionByAccount.get(acc) || null,
2491
- lastActivityAt: this.lastActivityByAccount.get(acc) || null,
2492
- lastInboundAt: this.lastInboundByAccount.get(acc) || null,
2493
- lastOutboundAt: this.lastOutboundByAccount.get(acc) || null,
2494
- sessionRoutesCount: Array.from(this.sessionRoutes.values()).filter((v) => v.accountId === acc)
2495
- .length,
2496
- invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(acc),
2497
- legacyAccountResidue: this.countLegacyAccountResidue(acc),
2498
- channelRoot: path.join(process.cwd(), 'plugins', 'bncr'),
2499
- });
3934
+ return buildStatusHeadlineFromRuntime(this.buildRuntimeStatusInput(accountId));
2500
3935
  }
2501
3936
 
2502
3937
  getStatusHeadline(accountId: string): string {
@@ -2524,22 +3959,21 @@ class BncrBridgeRuntime {
2524
3959
  }
2525
3960
 
2526
3961
  private enqueueOutbound(entry: OutboxEntry) {
2527
- const msg = (entry.payload as any)?.message || {};
2528
- const type = asString(msg.type || (entry.payload as any)?.type || 'unknown');
2529
- const text = asString(msg.msg || '');
2530
- const displayScope = formatDisplayScope(entry.route);
3962
+ // Structure note (outbox enqueue entrypoint):
3963
+ // This is the sync handoff from message construction into the outbound state machine.
3964
+ // Responsibilities are intentionally narrow here: log/summary, persist into outbox,
3965
+ // schedule state save, then nudge flushPushQueue. Future refactors should keep enqueue
3966
+ // lightweight and avoid reintroducing retry / route / ACK policy decisions at this layer.
2531
3967
  this.logInfo(
2532
3968
  'outbound',
2533
- JSON.stringify({
2534
- bridge: this.bridgeId,
2535
- messageId: entry.messageId,
2536
- accountId: entry.accountId,
2537
- sessionKey: entry.sessionKey,
2538
- scope: displayScope,
2539
- type,
2540
- textLen: text.length,
2541
- textPreview: text.slice(0, 120),
2542
- }),
3969
+ JSON.stringify(
3970
+ buildOutboxEnqueueDebugInfo({
3971
+ bridgeId: this.bridgeId,
3972
+ entry,
3973
+ asString,
3974
+ formatDisplayScope,
3975
+ }),
3976
+ ),
2543
3977
  { debugOnly: true },
2544
3978
  );
2545
3979
  this.logOutboundSummary(entry);
@@ -2549,50 +3983,46 @@ class BncrBridgeRuntime {
2549
3983
  }
2550
3984
 
2551
3985
  private moveToDeadLetter(entry: OutboxEntry, reason: string) {
2552
- const dead: OutboxEntry = {
2553
- ...entry,
2554
- lastError: reason,
2555
- };
2556
- this.deadLetter.push(dead);
2557
- if (this.deadLetter.length > 1000) this.deadLetter = this.deadLetter.slice(-1000);
3986
+ // Structure note (terminal transition):
3987
+ // Dead-lettering is the terminal state transition for an outbox entry. It also resolves any
3988
+ // waiter still blocked on the message id with timeout semantics, so future extraction should
3989
+ // treat dead-letter storage and waiter cleanup as one boundary rather than separate utilities.
3990
+ //
3991
+ // Queue-lifecycle note:
3992
+ // This path is shared by both explicit fatal outcomes and retry exhaustion. Keep that distinction
3993
+ // visible in callers, but keep the final sink centralized here so terminal accounting, persistence,
3994
+ // and waiter cleanup cannot drift apart.
3995
+ const dead = buildDeadLetterEntry(entry, reason);
3996
+ this.deadLetter = appendDeadLetter({
3997
+ deadLetter: this.deadLetter,
3998
+ entry: dead,
3999
+ maxEntries: 1000,
4000
+ });
2558
4001
  this.outbox.delete(entry.messageId);
2559
4002
  this.resolveMessageAck(entry.messageId, 'timeout');
2560
4003
  this.scheduleSave();
2561
4004
  }
2562
4005
 
2563
4006
  private collectDue(accountId: string, maxBatch: number): Array<Record<string, unknown>> {
2564
- const due: Array<Record<string, unknown>> = [];
2565
- const t = now();
2566
4007
  const key = normalizeAccountId(accountId);
4008
+ const result = collectDueOutboxEntries({
4009
+ outbox: this.outbox.values(),
4010
+ accountId: key,
4011
+ now: now(),
4012
+ maxBatch,
4013
+ maxRetry: MAX_RETRY,
4014
+ backoffMs,
4015
+ });
2567
4016
 
2568
- for (const entry of this.outbox.values()) {
2569
- if (entry.accountId !== key) continue;
2570
- if (entry.nextAttemptAt > t) continue;
2571
-
2572
- const nextAttempt = entry.retryCount + 1;
2573
- if (nextAttempt > MAX_RETRY) {
2574
- this.moveToDeadLetter(entry, 'retry-limit');
2575
- continue;
2576
- }
2577
-
2578
- entry.retryCount = nextAttempt;
2579
- entry.lastAttemptAt = t;
2580
- entry.nextAttemptAt = t + backoffMs(nextAttempt);
4017
+ for (const entry of result.updatedEntries) {
2581
4018
  this.outbox.set(entry.messageId, entry);
2582
-
2583
- due.push({
2584
- ...entry.payload,
2585
- _meta: {
2586
- retryCount: entry.retryCount,
2587
- nextAttemptAt: entry.nextAttemptAt,
2588
- },
2589
- });
2590
-
2591
- if (due.length >= maxBatch) break;
4019
+ }
4020
+ for (const entry of result.deadLetterEntries) {
4021
+ this.moveToDeadLetter(entry, entry.lastError || 'retry-limit');
2592
4022
  }
2593
4023
 
2594
- if (due.length) this.scheduleSave();
2595
- return due;
4024
+ if (result.duePayloads.length) this.scheduleSave();
4025
+ return result.duePayloads;
2596
4026
  }
2597
4027
 
2598
4028
  private async payloadMediaToBase64(
@@ -2610,6 +4040,301 @@ class BncrBridgeRuntime {
2610
4040
  };
2611
4041
  }
2612
4042
 
4043
+ private async loadOutboundTransferMedia(params: {
4044
+ mediaUrl: string;
4045
+ mediaLocalRoots?: readonly string[];
4046
+ }): Promise<{
4047
+ loaded: Awaited<ReturnType<OpenClawPluginApi['runtime']['media']['loadWebMedia']>>;
4048
+ size: number;
4049
+ mimeType?: string;
4050
+ fileName: string;
4051
+ }> {
4052
+ const loaded = await this.api.runtime.media.loadWebMedia(params.mediaUrl, {
4053
+ localRoots: params.mediaLocalRoots,
4054
+ maxBytes: 50 * 1024 * 1024,
4055
+ });
4056
+ const size = loaded.buffer.byteLength;
4057
+ const mimeType = loaded.contentType;
4058
+ const fileName = resolveOutboundFileName({
4059
+ mediaUrl: params.mediaUrl,
4060
+ fileName: loaded.fileName,
4061
+ mimeType,
4062
+ });
4063
+ return { loaded, size, mimeType, fileName };
4064
+ }
4065
+
4066
+ private buildTransferRouteDiagnostics(args: {
4067
+ accountId: string;
4068
+ recentInboundReachable: boolean;
4069
+ }) {
4070
+ const directConnIds = this.resolvePushConnIds(args.accountId);
4071
+ const recentConnIds = args.recentInboundReachable
4072
+ ? this.resolveRecentInboundConnIds(args.accountId)
4073
+ : new Set<string>();
4074
+ const activeConnectionKey = this.activeConnectionByAccount.get(args.accountId) || null;
4075
+ const accountConnections = Array.from(this.connections.values())
4076
+ .filter((c) => c.accountId === args.accountId)
4077
+ .map((c) => ({
4078
+ connId: c.connId,
4079
+ clientId: c.clientId,
4080
+ connectedAt: c.connectedAt,
4081
+ lastSeenAt: c.lastSeenAt,
4082
+ }));
4083
+
4084
+ return {
4085
+ directConnIds,
4086
+ recentConnIds,
4087
+ activeConnectionKey,
4088
+ accountConnections,
4089
+ };
4090
+ }
4091
+
4092
+ private selectTransferConnIds(args: {
4093
+ directConnIds: Set<string>;
4094
+ recentConnIds: Set<string>;
4095
+ recentInboundReachable: boolean;
4096
+ }) {
4097
+ let connIds = args.directConnIds;
4098
+ if (!connIds.size && args.recentInboundReachable) {
4099
+ connIds = args.recentConnIds;
4100
+ }
4101
+ return connIds;
4102
+ }
4103
+
4104
+ private logFileChunkDiag(args: {
4105
+ accountId: string;
4106
+ sessionKey: string;
4107
+ mediaUrl: string;
4108
+ hasGatewayContext: boolean;
4109
+ activeConnectionKey: string | null;
4110
+ ownerConnId?: string;
4111
+ ownerClientId?: string;
4112
+ directConnIds: Iterable<string>;
4113
+ recentInboundReachable: boolean;
4114
+ recentConnIds: Iterable<string>;
4115
+ accountConnections: Array<{
4116
+ connId: string;
4117
+ clientId?: string;
4118
+ connectedAt: number;
4119
+ lastSeenAt: number;
4120
+ }>;
4121
+ }) {
4122
+ this.logInfo(
4123
+ 'file-chunk-diag',
4124
+ JSON.stringify({
4125
+ bridge: this.bridgeId,
4126
+ accountId: args.accountId,
4127
+ sessionKey: args.sessionKey,
4128
+ mediaUrl: args.mediaUrl,
4129
+ hasGatewayContext: args.hasGatewayContext,
4130
+ activeConnectionKey: args.activeConnectionKey,
4131
+ ownerConnId: args.ownerConnId || null,
4132
+ ownerClientId: args.ownerClientId || null,
4133
+ directConnIds: Array.from(args.directConnIds),
4134
+ recentInboundReachable: args.recentInboundReachable,
4135
+ recentConnIds: Array.from(args.recentConnIds),
4136
+ accountConnections: args.accountConnections,
4137
+ }),
4138
+ { debugOnly: true },
4139
+ );
4140
+ }
4141
+
4142
+ private logFileTransferStart(args: {
4143
+ transferId: string;
4144
+ accountId: string;
4145
+ sessionKey: string;
4146
+ mediaUrl: string;
4147
+ fileName: string;
4148
+ mimeType?: string;
4149
+ fileSize: number;
4150
+ chunkSize: number;
4151
+ totalChunks: number;
4152
+ connIds: Iterable<string>;
4153
+ ownerConnId?: string;
4154
+ ownerClientId?: string;
4155
+ }) {
4156
+ this.logInfo(
4157
+ 'file-transfer-start',
4158
+ JSON.stringify({
4159
+ bridge: this.bridgeId,
4160
+ transferId: args.transferId,
4161
+ accountId: args.accountId,
4162
+ sessionKey: args.sessionKey,
4163
+ mediaUrl: args.mediaUrl,
4164
+ fileName: args.fileName,
4165
+ mimeType: args.mimeType,
4166
+ fileSize: args.fileSize,
4167
+ chunkSize: args.chunkSize,
4168
+ totalChunks: args.totalChunks,
4169
+ connIds: Array.from(args.connIds),
4170
+ ownerConnId: args.ownerConnId || null,
4171
+ ownerClientId: args.ownerClientId || null,
4172
+ }),
4173
+ { debugOnly: true },
4174
+ );
4175
+ }
4176
+
4177
+ private logFileTransferChunkSend(args: {
4178
+ transferId: string;
4179
+ accountId: string;
4180
+ chunkIndex: number;
4181
+ attempt: number;
4182
+ offset: number;
4183
+ size: number;
4184
+ connIds: Iterable<string>;
4185
+ }) {
4186
+ this.logInfo(
4187
+ 'file-transfer-chunk-send',
4188
+ JSON.stringify({
4189
+ bridge: this.bridgeId,
4190
+ transferId: args.transferId,
4191
+ accountId: args.accountId,
4192
+ chunkIndex: args.chunkIndex,
4193
+ attempt: args.attempt,
4194
+ offset: args.offset,
4195
+ size: args.size,
4196
+ connIds: Array.from(args.connIds),
4197
+ }),
4198
+ { debugOnly: true },
4199
+ );
4200
+ }
4201
+
4202
+ private logFileTransferChunkAck(args: {
4203
+ transferId: string;
4204
+ accountId: string;
4205
+ chunkIndex: number;
4206
+ attempt: number;
4207
+ }) {
4208
+ this.logInfo(
4209
+ 'file-transfer-chunk-ack',
4210
+ JSON.stringify({
4211
+ bridge: this.bridgeId,
4212
+ transferId: args.transferId,
4213
+ accountId: args.accountId,
4214
+ chunkIndex: args.chunkIndex,
4215
+ attempt: args.attempt,
4216
+ }),
4217
+ { debugOnly: true },
4218
+ );
4219
+ }
4220
+
4221
+ private logFileTransferChunkAckFail(args: {
4222
+ transferId: string;
4223
+ accountId: string;
4224
+ chunkIndex: number;
4225
+ attempt: number;
4226
+ error: unknown;
4227
+ }) {
4228
+ this.logWarn(
4229
+ 'file-transfer-chunk-ack-fail',
4230
+ JSON.stringify({
4231
+ bridge: this.bridgeId,
4232
+ transferId: args.transferId,
4233
+ accountId: args.accountId,
4234
+ chunkIndex: args.chunkIndex,
4235
+ attempt: args.attempt,
4236
+ error: asString((args.error as Error)?.message || args.error),
4237
+ }),
4238
+ { debugOnly: true },
4239
+ );
4240
+ }
4241
+
4242
+ private logFileTransferCompleteSend(args: {
4243
+ transferId: string;
4244
+ accountId: string;
4245
+ connIds: Iterable<string>;
4246
+ }) {
4247
+ this.logInfo(
4248
+ 'file-transfer-complete-send',
4249
+ JSON.stringify({
4250
+ bridge: this.bridgeId,
4251
+ transferId: args.transferId,
4252
+ accountId: args.accountId,
4253
+ connIds: Array.from(args.connIds),
4254
+ }),
4255
+ { debugOnly: true },
4256
+ );
4257
+ }
4258
+
4259
+ private logFileTransferCompleteAck(args: {
4260
+ transferId: string;
4261
+ accountId: string;
4262
+ payload: { path: string };
4263
+ }) {
4264
+ this.logInfo(
4265
+ 'file-transfer-complete-ack',
4266
+ JSON.stringify({
4267
+ bridge: this.bridgeId,
4268
+ transferId: args.transferId,
4269
+ accountId: args.accountId,
4270
+ payload: args.payload,
4271
+ }),
4272
+ { debugOnly: true },
4273
+ );
4274
+ }
4275
+
4276
+ private buildFileTransferInitPayload(args: {
4277
+ transferId: string;
4278
+ sessionKey: string;
4279
+ route: BncrRoute;
4280
+ fileName: string;
4281
+ mimeType?: string;
4282
+ fileSize: number;
4283
+ chunkSize: number;
4284
+ totalChunks: number;
4285
+ fileSha256: string;
4286
+ }) {
4287
+ return {
4288
+ transferId: args.transferId,
4289
+ direction: 'oc2bncr' as const,
4290
+ sessionKey: args.sessionKey,
4291
+ platform: args.route.platform,
4292
+ groupId: args.route.groupId,
4293
+ userId: args.route.userId,
4294
+ fileName: args.fileName,
4295
+ mimeType: args.mimeType,
4296
+ fileSize: args.fileSize,
4297
+ chunkSize: args.chunkSize,
4298
+ totalChunks: args.totalChunks,
4299
+ fileSha256: args.fileSha256,
4300
+ ts: now(),
4301
+ };
4302
+ }
4303
+
4304
+ private buildInitialFileSendTransferState(args: {
4305
+ transferId: string;
4306
+ accountId: string;
4307
+ sessionKey: string;
4308
+ route: BncrRoute;
4309
+ fileName: string;
4310
+ mimeType?: string;
4311
+ fileSize: number;
4312
+ chunkSize: number;
4313
+ totalChunks: number;
4314
+ fileSha256: string;
4315
+ ownerConnId?: string;
4316
+ ownerClientId?: string;
4317
+ }): FileSendTransferState {
4318
+ return {
4319
+ transferId: args.transferId,
4320
+ accountId: normalizeAccountId(args.accountId),
4321
+ sessionKey: args.sessionKey,
4322
+ route: args.route,
4323
+ fileName: args.fileName,
4324
+ mimeType: args.mimeType || 'application/octet-stream',
4325
+ fileSize: args.fileSize,
4326
+ chunkSize: args.chunkSize,
4327
+ totalChunks: args.totalChunks,
4328
+ fileSha256: args.fileSha256,
4329
+ startedAt: now(),
4330
+ status: 'init',
4331
+ ackedChunks: new Set(),
4332
+ failedChunks: new Map(),
4333
+ ownerConnId: args.ownerConnId,
4334
+ ownerClientId: args.ownerClientId,
4335
+ };
4336
+ }
4337
+
2613
4338
  private async sleepMs(ms: number): Promise<void> {
2614
4339
  await new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, Number(ms || 0))));
2615
4340
  }
@@ -2619,6 +4344,10 @@ class BncrBridgeRuntime {
2619
4344
  chunkIndex: number;
2620
4345
  timeoutMs?: number;
2621
4346
  }): Promise<void> {
4347
+ // Refactor boundary note (file-transfer / ACK coupling):
4348
+ // Chunk-level ACK waiting is part of the file-transfer sub-protocol, but it depends directly on
4349
+ // mutable transfer runtime state in fileSendTransfers. If this is extracted later, preserve the
4350
+ // current state ownership and timeout semantics before moving polling/wait logic out to another file.
2622
4351
  const { transferId, chunkIndex } = params;
2623
4352
  const timeoutMs = Math.max(
2624
4353
  1_000,
@@ -2656,6 +4385,10 @@ class BncrBridgeRuntime {
2656
4385
  transferId: string;
2657
4386
  timeoutMs?: number;
2658
4387
  }): Promise<{ path: string }> {
4388
+ // Refactor boundary note (file-transfer completion):
4389
+ // Completion ACK waiting shares the same transfer lifecycle boundary as chunk ACKs and relies on
4390
+ // transfer status transitions performed elsewhere in channel.ts. Keep completion wait behavior and
4391
+ // transfer-state mutation boundaries aligned if/when file-transfer pieces are moved out.
2659
4392
  const { transferId } = params;
2660
4393
  const timeoutMs = Math.max(2_000, Math.min(Number(params.timeoutMs || 60_000), 120_000));
2661
4394
  const started = now();
@@ -2693,23 +4426,20 @@ class BncrBridgeRuntime {
2693
4426
  mediaUrl: string;
2694
4427
  mediaLocalRoots?: readonly string[];
2695
4428
  }): Promise<{
4429
+ // Refactor boundary note (file-transfer root):
4430
+ // This method is the root of the outbound file-transfer protocol. It owns media loading,
4431
+ // inline-vs-chunk mode selection, route/owner selection for transfer delivery, chunk send,
4432
+ // chunk ACK waits, complete ACK waits, and abort propagation. Future extraction should treat
4433
+ // these as one protocol boundary first, rather than splitting transport and state handling separately.
2696
4434
  mode: 'base64' | 'chunk';
2697
4435
  mimeType?: string;
2698
4436
  fileName?: string;
2699
4437
  mediaBase64?: string;
2700
4438
  path?: string;
2701
4439
  }> {
2702
- const loaded = await this.api.runtime.media.loadWebMedia(params.mediaUrl, {
2703
- localRoots: params.mediaLocalRoots,
2704
- maxBytes: 50 * 1024 * 1024,
2705
- });
2706
-
2707
- const size = loaded.buffer.byteLength;
2708
- const mimeType = loaded.contentType;
2709
- const fileName = resolveOutboundFileName({
4440
+ const { loaded, size, mimeType, fileName } = await this.loadOutboundTransferMedia({
2710
4441
  mediaUrl: params.mediaUrl,
2711
- fileName: loaded.fileName,
2712
- mimeType,
4442
+ mediaLocalRoots: params.mediaLocalRoots,
2713
4443
  });
2714
4444
 
2715
4445
  if (!FILE_FORCE_CHUNK && size <= FILE_INLINE_THRESHOLD) {
@@ -2724,44 +4454,31 @@ class BncrBridgeRuntime {
2724
4454
  const ctx = this.gatewayContext;
2725
4455
  const owner = this.resolveOutboxPushOwner(params.accountId);
2726
4456
  const recentInboundReachable = this.hasRecentInboundReachability(params.accountId);
2727
- const directConnIds = this.resolvePushConnIds(params.accountId);
2728
- const recentConnIds = recentInboundReachable
2729
- ? this.resolveRecentInboundConnIds(params.accountId)
2730
- : new Set<string>();
2731
4457
  const accountId = normalizeAccountId(params.accountId);
2732
- const activeConnectionKey = this.activeConnectionByAccount.get(accountId) || null;
2733
- const accountConnections = Array.from(this.connections.values())
2734
- .filter((c) => c.accountId === accountId)
2735
- .map((c) => ({
2736
- connId: c.connId,
2737
- clientId: c.clientId,
2738
- connectedAt: c.connectedAt,
2739
- lastSeenAt: c.lastSeenAt,
2740
- }));
2741
- this.logInfo(
2742
- 'file-chunk-diag',
2743
- JSON.stringify({
2744
- bridge: this.bridgeId,
2745
- accountId,
2746
- sessionKey: params.sessionKey,
2747
- mediaUrl: params.mediaUrl,
2748
- hasGatewayContext: Boolean(ctx),
2749
- activeConnectionKey,
2750
- ownerConnId: owner?.connId || null,
2751
- ownerClientId: owner?.clientId || null,
2752
- directConnIds: Array.from(directConnIds),
2753
- recentInboundReachable,
2754
- recentConnIds: Array.from(recentConnIds),
2755
- accountConnections,
2756
- }),
2757
- { debugOnly: true },
2758
- );
4458
+ const routeDiagnostics = this.buildTransferRouteDiagnostics({
4459
+ accountId,
4460
+ recentInboundReachable,
4461
+ });
4462
+ this.logFileChunkDiag({
4463
+ accountId,
4464
+ sessionKey: params.sessionKey,
4465
+ mediaUrl: params.mediaUrl,
4466
+ hasGatewayContext: Boolean(ctx),
4467
+ activeConnectionKey: routeDiagnostics.activeConnectionKey,
4468
+ ownerConnId: owner?.connId,
4469
+ ownerClientId: owner?.clientId,
4470
+ directConnIds: routeDiagnostics.directConnIds,
4471
+ recentInboundReachable,
4472
+ recentConnIds: routeDiagnostics.recentConnIds,
4473
+ accountConnections: routeDiagnostics.accountConnections,
4474
+ });
2759
4475
  if (!ctx) throw new Error('gateway context unavailable');
2760
4476
 
2761
- let connIds = directConnIds;
2762
- if (!connIds.size && recentInboundReachable) {
2763
- connIds = recentConnIds;
2764
- }
4477
+ const connIds = this.selectTransferConnIds({
4478
+ directConnIds: routeDiagnostics.directConnIds,
4479
+ recentConnIds: routeDiagnostics.recentConnIds,
4480
+ recentInboundReachable,
4481
+ });
2765
4482
  if (!connIds.size) throw new Error('no active bncr client for file chunk transfer');
2766
4483
 
2767
4484
  const transferId = randomUUID();
@@ -2769,63 +4486,50 @@ class BncrBridgeRuntime {
2769
4486
  const totalChunks = Math.ceil(size / chunkSize);
2770
4487
  const fileSha256 = createHash('sha256').update(loaded.buffer).digest('hex');
2771
4488
 
2772
- this.logInfo(
2773
- 'file-transfer-start',
2774
- JSON.stringify({
2775
- bridge: this.bridgeId,
2776
- transferId,
2777
- accountId,
2778
- sessionKey: params.sessionKey,
2779
- mediaUrl: params.mediaUrl,
2780
- fileName,
2781
- mimeType,
2782
- fileSize: size,
2783
- chunkSize,
2784
- totalChunks,
2785
- connIds: Array.from(connIds),
2786
- ownerConnId: owner?.connId || null,
2787
- ownerClientId: owner?.clientId || null,
2788
- }),
2789
- { debugOnly: true },
2790
- );
4489
+ this.logFileTransferStart({
4490
+ transferId,
4491
+ accountId,
4492
+ sessionKey: params.sessionKey,
4493
+ mediaUrl: params.mediaUrl,
4494
+ fileName,
4495
+ mimeType,
4496
+ fileSize: size,
4497
+ chunkSize,
4498
+ totalChunks,
4499
+ connIds,
4500
+ ownerConnId: owner?.connId,
4501
+ ownerClientId: owner?.clientId,
4502
+ });
2791
4503
 
2792
- const st: FileSendTransferState = {
4504
+ const st = this.buildInitialFileSendTransferState({
2793
4505
  transferId,
2794
- accountId: normalizeAccountId(params.accountId),
4506
+ accountId: params.accountId,
2795
4507
  sessionKey: params.sessionKey,
2796
4508
  route: params.route,
2797
4509
  fileName,
2798
- mimeType: mimeType || 'application/octet-stream',
4510
+ mimeType,
2799
4511
  fileSize: size,
2800
4512
  chunkSize,
2801
4513
  totalChunks,
2802
4514
  fileSha256,
2803
- startedAt: now(),
2804
- status: 'init',
2805
- ackedChunks: new Set(),
2806
- failedChunks: new Map(),
2807
4515
  ownerConnId: owner?.connId,
2808
4516
  ownerClientId: owner?.clientId,
2809
- };
4517
+ });
2810
4518
  this.fileSendTransfers.set(transferId, st);
2811
4519
 
2812
4520
  ctx.broadcastToConnIds(
2813
4521
  BNCR_FILE_INIT_EVENT,
2814
- {
4522
+ this.buildFileTransferInitPayload({
2815
4523
  transferId,
2816
- direction: 'oc2bncr',
2817
4524
  sessionKey: params.sessionKey,
2818
- platform: params.route.platform,
2819
- groupId: params.route.groupId,
2820
- userId: params.route.userId,
4525
+ route: params.route,
2821
4526
  fileName,
2822
4527
  mimeType,
2823
4528
  fileSize: size,
2824
4529
  chunkSize,
2825
4530
  totalChunks,
2826
4531
  fileSha256,
2827
- ts: now(),
2828
- },
4532
+ }),
2829
4533
  connIds,
2830
4534
  );
2831
4535
 
@@ -2853,20 +4557,15 @@ class BncrBridgeRuntime {
2853
4557
  connIds,
2854
4558
  );
2855
4559
 
2856
- this.logInfo(
2857
- 'file-transfer-chunk-send',
2858
- JSON.stringify({
2859
- bridge: this.bridgeId,
2860
- transferId,
2861
- accountId,
2862
- chunkIndex: idx,
2863
- attempt,
2864
- offset: start,
2865
- size: slice.byteLength,
2866
- connIds: Array.from(connIds),
2867
- }),
2868
- { debugOnly: true },
2869
- );
4560
+ this.logFileTransferChunkSend({
4561
+ transferId,
4562
+ accountId,
4563
+ chunkIndex: idx,
4564
+ attempt,
4565
+ offset: start,
4566
+ size: slice.byteLength,
4567
+ connIds,
4568
+ });
2870
4569
 
2871
4570
  try {
2872
4571
  await this.waitChunkAck({
@@ -2874,33 +4573,23 @@ class BncrBridgeRuntime {
2874
4573
  chunkIndex: idx,
2875
4574
  timeoutMs: FILE_TRANSFER_ACK_TTL_MS,
2876
4575
  });
2877
- this.logInfo(
2878
- 'file-transfer-chunk-ack',
2879
- JSON.stringify({
2880
- bridge: this.bridgeId,
2881
- transferId,
2882
- accountId,
2883
- chunkIndex: idx,
2884
- attempt,
2885
- }),
2886
- { debugOnly: true },
2887
- );
4576
+ this.logFileTransferChunkAck({
4577
+ transferId,
4578
+ accountId,
4579
+ chunkIndex: idx,
4580
+ attempt,
4581
+ });
2888
4582
  ok = true;
2889
4583
  break;
2890
4584
  } catch (err) {
2891
4585
  lastErr = err;
2892
- this.logWarn(
2893
- 'file-transfer-chunk-ack-fail',
2894
- JSON.stringify({
2895
- bridge: this.bridgeId,
2896
- transferId,
2897
- accountId,
2898
- chunkIndex: idx,
2899
- attempt,
2900
- error: asString((err as Error)?.message || err),
2901
- }),
2902
- { debugOnly: true },
2903
- );
4586
+ this.logFileTransferChunkAckFail({
4587
+ transferId,
4588
+ accountId,
4589
+ chunkIndex: idx,
4590
+ attempt,
4591
+ error: err,
4592
+ });
2904
4593
  await this.sleepMs(150 * attempt);
2905
4594
  }
2906
4595
  }
@@ -2931,29 +4620,19 @@ class BncrBridgeRuntime {
2931
4620
  connIds,
2932
4621
  );
2933
4622
 
2934
- this.logInfo(
2935
- 'file-transfer-complete-send',
2936
- JSON.stringify({
2937
- bridge: this.bridgeId,
2938
- transferId,
2939
- accountId,
2940
- connIds: Array.from(connIds),
2941
- }),
2942
- { debugOnly: true },
2943
- );
4623
+ this.logFileTransferCompleteSend({
4624
+ transferId,
4625
+ accountId,
4626
+ connIds,
4627
+ });
2944
4628
 
2945
4629
  const done = await this.waitCompleteAck({ transferId, timeoutMs: 60_000 });
2946
4630
 
2947
- this.logInfo(
2948
- 'file-transfer-complete-ack',
2949
- JSON.stringify({
2950
- bridge: this.bridgeId,
2951
- transferId,
2952
- accountId,
2953
- payload: done,
2954
- }),
2955
- { debugOnly: true },
2956
- );
4631
+ this.logFileTransferCompleteAck({
4632
+ transferId,
4633
+ accountId,
4634
+ payload: done,
4635
+ });
2957
4636
 
2958
4637
  return {
2959
4638
  mode: 'chunk',
@@ -2967,81 +4646,101 @@ class BncrBridgeRuntime {
2967
4646
  accountId: string;
2968
4647
  sessionKey: string;
2969
4648
  route: BncrRoute;
2970
- payload: {
2971
- text?: string;
2972
- mediaUrl?: string;
2973
- mediaUrls?: string[];
2974
- asVoice?: boolean;
2975
- audioAsVoice?: boolean;
2976
- kind?: 'tool' | 'block' | 'final';
2977
- replyToId?: string;
2978
- };
4649
+ payload: ReplyPayloadInput;
2979
4650
  mediaLocalRoots?: readonly string[];
2980
4651
  }) {
2981
4652
  const { accountId, sessionKey, route, payload, mediaLocalRoots } = params;
4653
+ const normalized = normalizeReplyPayload(payload, { asString });
2982
4654
 
2983
- const mediaList = payload.mediaUrls?.length
2984
- ? payload.mediaUrls
2985
- : payload.mediaUrl
2986
- ? [payload.mediaUrl]
2987
- : [];
2988
-
2989
- if (mediaList.length > 0) {
2990
- let first = true;
2991
- for (const mediaUrl of mediaList) {
2992
- this.enqueueOutbound(
2993
- this.buildFileTransferOutboxEntry({
2994
- accountId,
2995
- sessionKey,
2996
- route,
2997
- mediaUrl,
2998
- mediaLocalRoots,
2999
- text: first ? asString(payload.text || '') : '',
3000
- asVoice: payload.asVoice,
3001
- audioAsVoice: payload.audioAsVoice,
3002
- kind: payload.kind,
3003
- replyToId: asString(payload.replyToId || '').trim() || undefined,
4655
+ enqueueNormalizedReplyPayload(
4656
+ {
4657
+ accountId,
4658
+ sessionKey,
4659
+ route,
4660
+ payload: normalized,
4661
+ mediaLocalRoots,
4662
+ },
4663
+ {
4664
+ logEnqueueFromReply: (args) => this.logEnqueueFromReply(args),
4665
+ hasReplyMediaEntries,
4666
+ enqueueReplyMediaEntries: (args) => this.enqueueReplyMediaEntries(args),
4667
+ enqueueReplyTextEntry: (args) =>
4668
+ enqueueReplyTextEntry(args, {
4669
+ enqueueOutbound: (entry) => this.enqueueOutbound(entry),
4670
+ buildTextOutboxEntry: (entryArgs) => this.buildTextOutboxEntry(entryArgs),
3004
4671
  }),
3005
- );
3006
- first = false;
3007
- }
3008
- return;
3009
- }
4672
+ },
4673
+ );
4674
+ }
3010
4675
 
3011
- const text = asString(payload.text || '').trim();
3012
- if (!text) return;
4676
+ private logEnqueueFromReply(args: {
4677
+ accountId: string;
4678
+ sessionKey: string;
4679
+ route: BncrRoute;
4680
+ payload: NormalizedReplyPayload;
4681
+ }) {
4682
+ this.logInfo(
4683
+ 'outbound',
4684
+ `enqueue-from-reply ${JSON.stringify(buildEnqueueFromReplyDebugInfo(args))}`,
4685
+ { debugOnly: true },
4686
+ );
4687
+ }
3013
4688
 
3014
- const messageId = randomUUID();
3015
- const frame = {
3016
- type: 'message.outbound',
3017
- messageId,
3018
- idempotencyKey: messageId,
3019
- sessionKey,
3020
- replyToId: asString(payload.replyToId || '').trim() || undefined,
3021
- message: {
3022
- platform: route.platform,
3023
- groupId: route.groupId,
3024
- userId: route.userId,
3025
- type: 'text',
3026
- kind: payload.kind,
3027
- msg: text,
3028
- path: '',
3029
- base64: '',
3030
- fileName: '',
4689
+ private enqueueSingleReplyMediaEntry(args: {
4690
+ params: ReplyMediaEntriesParams;
4691
+ mediaUrl: string;
4692
+ first: boolean;
4693
+ currentTime: number;
4694
+ }) {
4695
+ const normalizedText = normalizeMessageText(args.first ? args.params.payload.text : '');
4696
+ const fallback = this.tryBuildMediaDedupeFallback({
4697
+ sessionKey: args.params.sessionKey,
4698
+ mediaUrl: args.mediaUrl,
4699
+ text: normalizedText,
4700
+ replyToId: args.params.payload.replyToId,
4701
+ currentTime: args.currentTime,
4702
+ });
4703
+
4704
+ enqueueSingleReplyMediaEntry(
4705
+ {
4706
+ params: args.params,
4707
+ mediaUrl: args.mediaUrl,
4708
+ normalizedText,
4709
+ text: args.first ? args.params.payload.text : '',
4710
+ fallback,
4711
+ currentTime: args.currentTime,
3031
4712
  },
3032
- ts: now(),
3033
- };
4713
+ {
4714
+ enqueueReplyMediaFallbackTextEntry: (params) =>
4715
+ enqueueReplyMediaFallbackTextEntry(params, {
4716
+ logInfo: (scope, message, options) => this.logInfo(scope, message, options),
4717
+ enqueueOutbound: (entry) => this.enqueueOutbound(entry),
4718
+ buildTextOutboxEntry: (entryParams) => this.buildTextOutboxEntry(entryParams),
4719
+ }),
4720
+ enqueueReplyMediaFileTransferEntry: (params) =>
4721
+ enqueueReplyMediaFileTransferEntry(params, {
4722
+ enqueueOutbound: (entry) => this.enqueueOutbound(entry),
4723
+ buildFileTransferOutboxEntry: (entryParams) =>
4724
+ this.buildFileTransferOutboxEntry(entryParams),
4725
+ rememberRecentMediaSend: (entryParams) => this.rememberRecentMediaSend(entryParams),
4726
+ }),
4727
+ },
4728
+ );
4729
+ }
3034
4730
 
3035
- this.enqueueOutbound({
3036
- messageId,
3037
- accountId: normalizeAccountId(accountId),
3038
- sessionKey,
3039
- route,
3040
- payload: frame,
3041
- createdAt: now(),
3042
- retryCount: 0,
3043
- nextAttemptAt: now(),
3044
- });
4731
+ private enqueueReplyMediaEntries(params: ReplyMediaEntriesParams) {
4732
+ let first = true;
4733
+ const currentTime = now();
4734
+
4735
+ for (const mediaUrl of params.payload.mediaList) {
4736
+ this.enqueueSingleReplyMediaEntry({
4737
+ params,
4738
+ mediaUrl,
4739
+ first,
4740
+ currentTime,
4741
+ });
4742
+ first = false;
4743
+ }
3045
4744
  }
3046
4745
 
3047
4746
  handleConnect = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
@@ -3049,6 +4748,9 @@ class BncrBridgeRuntime {
3049
4748
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
3050
4749
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
3051
4750
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
4751
+ const outboundReady = (params as any)?.outboundReady === true;
4752
+ const preferredForOutbound = (params as any)?.preferredForOutbound === true;
4753
+ const inboundOnly = (params as any)?.inboundOnly === true;
3052
4754
 
3053
4755
  this.logInfo(
3054
4756
  'connection',
@@ -3057,14 +4759,23 @@ class BncrBridgeRuntime {
3057
4759
  accountId,
3058
4760
  connId,
3059
4761
  clientId,
4762
+ outboundReady,
4763
+ preferredForOutbound,
4764
+ inboundOnly,
3060
4765
  hasContext: Boolean(context),
3061
4766
  })}`,
3062
4767
  { debugOnly: true },
3063
4768
  );
3064
4769
 
3065
- this.rememberGatewayContext(context);
3066
- this.markSeen(accountId, connId, clientId);
3067
- this.markActivity(accountId);
4770
+ this.refreshLiveConnectionState({
4771
+ accountId,
4772
+ connId,
4773
+ clientId,
4774
+ outboundReady,
4775
+ preferredForOutbound,
4776
+ inboundOnly,
4777
+ context,
4778
+ });
3068
4779
  this.incrementCounter(this.connectEventsByAccount, accountId);
3069
4780
  const lease = this.acceptConnection();
3070
4781
 
@@ -3093,113 +4804,41 @@ class BncrBridgeRuntime {
3093
4804
  now: now(),
3094
4805
  });
3095
4806
 
3096
- // WS 一旦在线,立即尝试把离线期间积压队列直推出去
3097
- this.flushPushQueue(accountId);
3098
- };
3099
-
3100
- handleAck = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
3101
- await this.syncDebugFlag();
3102
- const accountId = normalizeAccountId(asString(params?.accountId || ''));
3103
- const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
3104
- const clientId = asString((params as any)?.clientId || '').trim() || undefined;
3105
- const messageId = asString(params?.messageId || '').trim();
3106
- const staleObserved = this.observeLease('ack', params ?? {});
3107
-
3108
- this.logInfo(
3109
- 'outbox',
3110
- `ack ${JSON.stringify({
3111
- accountId,
3112
- messageId,
3113
- ok: params?.ok !== false,
3114
- fatal: params?.fatal === true,
3115
- error: asString(params?.error || ''),
3116
- stale: staleObserved.stale,
3117
- })}`,
3118
- { debugOnly: true },
3119
- );
3120
- if (!messageId) {
3121
- respond(false, { error: 'messageId required' });
3122
- return;
3123
- }
3124
-
3125
- const entry = this.outbox.get(messageId);
3126
- if (!entry) {
3127
- respond(true, { ok: true, message: 'already-acked-or-missing', stale: staleObserved.stale });
3128
- return;
3129
- }
3130
-
3131
- if (entry.accountId !== accountId) {
3132
- respond(false, { error: 'account mismatch' });
3133
- return;
3134
- }
3135
-
3136
- if (staleObserved.stale) {
3137
- const sameConn = !!entry.lastPushConnId && entry.lastPushConnId === connId;
3138
- const sameClient =
3139
- !entry.lastPushConnId &&
3140
- !!entry.lastPushClientId &&
3141
- !!clientId &&
3142
- entry.lastPushClientId === clientId;
3143
- if (!(sameConn || sameClient)) {
3144
- this.logWarn(
3145
- 'stale',
3146
- `ignore kind=ack accountId=${accountId} connId=${connId} clientId=${clientId || '-'} messageId=${messageId} reason=owner-mismatch lastPushConnId=${entry.lastPushConnId || '-'} lastPushClientId=${entry.lastPushClientId || '-'}`,
3147
- { debugOnly: true },
3148
- );
3149
- respond(true, { ok: true, stale: true, ignored: true });
3150
- return;
3151
- }
3152
- } else {
3153
- this.rememberGatewayContext(context);
3154
- this.markSeen(accountId, connId, clientId);
3155
- }
3156
- this.lastAckAtGlobal = now();
3157
- this.incrementCounter(this.ackEventsByAccount, accountId);
3158
-
3159
- const ok = params?.ok !== false;
3160
- const fatal = params?.fatal === true;
3161
-
3162
- if (ok) {
3163
- this.outbox.delete(messageId);
3164
- this.scheduleSave();
3165
- this.resolveMessageAck(messageId, 'acked');
3166
- respond(
3167
- true,
3168
- staleObserved.stale ? { ok: true, stale: true, staleAccepted: true } : { ok: true },
3169
- );
3170
- this.flushPushQueue(accountId);
3171
- return;
3172
- }
3173
-
3174
- if (fatal) {
3175
- this.moveToDeadLetter(entry, asString(params?.error || 'fatal-ack'));
3176
- respond(
3177
- true,
3178
- staleObserved.stale
3179
- ? { ok: true, movedToDeadLetter: true, stale: true, staleAccepted: true }
3180
- : { ok: true, movedToDeadLetter: true },
3181
- );
3182
- return;
3183
- }
4807
+ // WS 一旦在线,立即尝试把离线期间积压队列直推出去
4808
+ this.flushPushQueue({
4809
+ accountId,
4810
+ trigger: OUTBOUND_FLUSH_TRIGGER.CONNECT,
4811
+ reason: OUTBOUND_FLUSH_REASON.WS_ONLINE,
4812
+ });
4813
+ };
3184
4814
 
3185
- entry.nextAttemptAt = now() + 1_000;
3186
- entry.lastError = asString(params?.error || 'retryable-ack');
3187
- this.outbox.set(messageId, entry);
3188
- this.scheduleSave();
4815
+ handleAck = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
4816
+ // Structure note (explicit ACK event boundary):
4817
+ // Successful ACK events are the authoritative source for removing outbox entries and resolving
4818
+ // message-ack waiters. flushPushQueue may wait for ACKs, but it is not the source of truth for
4819
+ // final entry deletion. Keep this boundary explicit in future refactors.
4820
+ await this.syncDebugFlag();
4821
+ const prepared = this.prepareAckHandling({ params, respond, client, context });
4822
+ if (!prepared) return;
3189
4823
 
3190
- respond(
3191
- true,
3192
- staleObserved.stale
3193
- ? { ok: true, willRetry: true, stale: true, staleAccepted: true }
3194
- : { ok: true, willRetry: true },
3195
- );
4824
+ const { accountId } = prepared;
4825
+ this.lastAckAtGlobal = now();
4826
+ this.incrementCounter(this.ackEventsByAccount, accountId);
4827
+ this.handleAckOutcome({ params, respond, ...prepared });
3196
4828
  };
3197
4829
 
3198
4830
  handleActivity = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
4831
+ // Structure note (activity-driven flush nudge):
4832
+ // Activity events refresh liveness/capability state first, then nudge outbound draining.
4833
+ // They are not a retry policy engine by themselves; they only give the scheduler a better
4834
+ // chance to drain with fresher reachability information.
3199
4835
  await this.syncDebugFlag();
3200
4836
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
3201
4837
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
3202
4838
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
4839
+ const outboundReady = (params as any)?.outboundReady === true;
4840
+ const preferredForOutbound = (params as any)?.preferredForOutbound === true;
4841
+ const inboundOnly = (params as any)?.inboundOnly === true;
3203
4842
  if (
3204
4843
  this.shouldIgnoreStaleEvent({
3205
4844
  kind: 'activity',
@@ -3220,13 +4859,22 @@ class BncrBridgeRuntime {
3220
4859
  accountId,
3221
4860
  connId,
3222
4861
  clientId,
4862
+ outboundReady,
4863
+ preferredForOutbound,
4864
+ inboundOnly,
3223
4865
  hasContext: Boolean(context),
3224
4866
  })}`,
3225
4867
  { debugOnly: true },
3226
4868
  );
3227
- this.rememberGatewayContext(context);
3228
- this.markSeen(accountId, connId, clientId);
3229
- this.markActivity(accountId);
4869
+ this.refreshLiveConnectionState({
4870
+ accountId,
4871
+ connId,
4872
+ clientId,
4873
+ outboundReady,
4874
+ preferredForOutbound,
4875
+ inboundOnly,
4876
+ context,
4877
+ });
3230
4878
  this.incrementCounter(this.activityEventsByAccount, accountId);
3231
4879
 
3232
4880
  // 轻量活动心跳:仅刷新在线活跃状态,不承担拉取职责。
@@ -3239,7 +4887,11 @@ class BncrBridgeRuntime {
3239
4887
  deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
3240
4888
  now: now(),
3241
4889
  });
3242
- this.flushPushQueue(accountId);
4890
+ this.flushPushQueue({
4891
+ accountId,
4892
+ trigger: OUTBOUND_FLUSH_TRIGGER.ACTIVITY,
4893
+ reason: OUTBOUND_FLUSH_REASON.ACTIVITY_HEARTBEAT,
4894
+ });
3243
4895
  };
3244
4896
 
3245
4897
  handleDiagnostics = async ({ params, respond }: GatewayRequestHandlerOptions) => {
@@ -3247,37 +4899,27 @@ class BncrBridgeRuntime {
3247
4899
  const cfg = this.api.runtime.config.current();
3248
4900
  const runtime = this.getAccountRuntimeSnapshot(accountId);
3249
4901
  const diagnostics = this.buildExtendedDiagnostics(accountId);
3250
- const permissions = buildBncrPermissionSummary(cfg ?? {});
3251
- const probe = probeBncrAccount({
3252
- accountId,
3253
- connected: Boolean(runtime?.connected),
3254
- pending: Number(runtime?.meta?.pending ?? 0),
3255
- deadLetter: Number(runtime?.meta?.deadLetter ?? 0),
3256
- activeConnections: this.activeConnectionCount(accountId),
3257
- invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(accountId),
3258
- legacyAccountResidue: this.countLegacyAccountResidue(accountId),
3259
- lastActivityAt: runtime?.meta?.lastActivityAt ?? null,
3260
- structure: {
3261
- coreComplete: true,
3262
- inboundComplete: true,
3263
- outboundComplete: true,
3264
- },
3265
- });
3266
4902
 
3267
- respond(true, {
3268
- channel: CHANNEL_ID,
3269
- accountId,
3270
- runtime,
3271
- diagnostics,
3272
- runtimeFlags: this.buildRuntimeFlags(accountId),
3273
- waiters: {
3274
- messageAck: this.messageAckWaiters.size,
3275
- fileAck: this.fileAckWaiters.size,
3276
- },
3277
- permissions,
3278
- probe,
3279
- now: now(),
3280
- });
4903
+ respond(
4904
+ true,
4905
+ buildDiagnosticsPayload({
4906
+ cfg,
4907
+ channelId: CHANNEL_ID,
4908
+ accountId,
4909
+ runtime,
4910
+ diagnostics,
4911
+ downlinkHealth: this.buildDownlinkHealth(accountId),
4912
+ runtimeFlags: this.buildRuntimeFlags(accountId),
4913
+ waiters: {
4914
+ messageAck: this.messageAckWaiters.size,
4915
+ fileAck: this.fileAckWaiters.size,
4916
+ },
4917
+ activeConnections: this.activeConnectionCount(accountId),
4918
+ invalidOutboxSessionKeys: this.countInvalidOutboxSessionKeys(accountId),
4919
+ legacyAccountResidue: this.countLegacyAccountResidue(accountId),
4920
+ now: now(),
4921
+ }),
4922
+ );
3281
4923
  };
3282
4924
 
3283
4925
  handleFileInit = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
@@ -3296,9 +4938,12 @@ class BncrBridgeRuntime {
3296
4938
  respond(true, { ok: true, stale: true, ignored: true });
3297
4939
  return;
3298
4940
  }
3299
- this.rememberGatewayContext(context);
3300
- this.markSeen(accountId, connId, clientId);
3301
- this.markActivity(accountId);
4941
+ this.refreshAcceptedFileTransferLiveState({
4942
+ accountId,
4943
+ connId,
4944
+ clientId,
4945
+ context,
4946
+ });
3302
4947
 
3303
4948
  const transferId = asString(params?.transferId || '').trim();
3304
4949
  const sessionKey = asString(params?.sessionKey || '').trim();
@@ -3406,9 +5051,12 @@ class BncrBridgeRuntime {
3406
5051
  return;
3407
5052
  }
3408
5053
  } else {
3409
- this.rememberGatewayContext(context);
3410
- this.markSeen(accountId, connId, clientId);
3411
- this.markActivity(accountId);
5054
+ this.refreshAcceptedFileTransferLiveState({
5055
+ accountId,
5056
+ connId,
5057
+ clientId,
5058
+ context,
5059
+ });
3412
5060
  }
3413
5061
 
3414
5062
  try {
@@ -3493,9 +5141,12 @@ class BncrBridgeRuntime {
3493
5141
  return;
3494
5142
  }
3495
5143
  } else {
3496
- this.rememberGatewayContext(context);
3497
- this.markSeen(accountId, connId, clientId);
3498
- this.markActivity(accountId);
5144
+ this.refreshAcceptedFileTransferLiveState({
5145
+ accountId,
5146
+ connId,
5147
+ clientId,
5148
+ context,
5149
+ });
3499
5150
  }
3500
5151
 
3501
5152
  try {
@@ -3596,9 +5247,12 @@ class BncrBridgeRuntime {
3596
5247
  return;
3597
5248
  }
3598
5249
  } else {
3599
- this.rememberGatewayContext(context);
3600
- this.markSeen(accountId, connId, clientId);
3601
- this.markActivity(accountId);
5250
+ this.refreshAcceptedFileTransferLiveState({
5251
+ accountId,
5252
+ connId,
5253
+ clientId,
5254
+ context,
5255
+ });
3602
5256
  }
3603
5257
 
3604
5258
  st.status = 'aborted';
@@ -3688,9 +5342,12 @@ class BncrBridgeRuntime {
3688
5342
  return;
3689
5343
  }
3690
5344
  } else {
3691
- this.rememberGatewayContext(context);
3692
- this.markSeen(accountId, connId, clientId);
3693
- this.markActivity(accountId);
5345
+ this.refreshAcceptedFileTransferLiveState({
5346
+ accountId,
5347
+ connId,
5348
+ clientId,
5349
+ context,
5350
+ });
3694
5351
  }
3695
5352
 
3696
5353
  if (st) {
@@ -3750,6 +5407,10 @@ class BncrBridgeRuntime {
3750
5407
  };
3751
5408
 
3752
5409
  handleInbound = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
5410
+ // Structure note (inbound-driven flush nudge):
5411
+ // Inbound acceptance is another explicit wake source for outbound draining. It should stay
5412
+ // separate from retry policy so later refactors can reason clearly about "new inbound signal"
5413
+ // versus "scheduled retry" versus "ACK-driven continuation".
3753
5414
  await this.syncDebugFlag();
3754
5415
  const parsed = parseBncrInboundParams(params);
3755
5416
  const {
@@ -3772,6 +5433,9 @@ class BncrBridgeRuntime {
3772
5433
  } = parsed;
3773
5434
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
3774
5435
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
5436
+ const outboundReady = (params as any)?.outboundReady === true;
5437
+ const preferredForOutbound = (params as any)?.preferredForOutbound === true;
5438
+ const inboundOnly = (params as any)?.inboundOnly === true;
3775
5439
  if (
3776
5440
  this.shouldIgnoreStaleEvent({
3777
5441
  kind: 'inbound',
@@ -3781,91 +5445,69 @@ class BncrBridgeRuntime {
3781
5445
  clientId,
3782
5446
  })
3783
5447
  ) {
3784
- respond(true, {
3785
- accepted: false,
3786
- stale: true,
3787
- ignored: true,
3788
- accountId,
3789
- msgId: msgId ?? null,
3790
- });
5448
+ respond(
5449
+ true,
5450
+ buildInboundResponsePayload({
5451
+ kind: 'stale-ignored',
5452
+ accountId,
5453
+ msgId: msgId ?? null,
5454
+ }),
5455
+ );
3791
5456
  return;
3792
5457
  }
3793
- this.rememberGatewayContext(context);
3794
- this.markSeen(accountId, connId, clientId);
3795
- this.markActivity(accountId);
5458
+ this.refreshLiveConnectionState({
5459
+ accountId,
5460
+ connId,
5461
+ clientId,
5462
+ outboundReady,
5463
+ preferredForOutbound,
5464
+ inboundOnly,
5465
+ context,
5466
+ });
3796
5467
  this.logInfo(
3797
5468
  'inbound',
3798
- `lifecycle ${JSON.stringify({
3799
- stage: 'accepted',
3800
- bridge: this.bridgeId,
3801
- accountId,
3802
- connId,
3803
- clientId,
3804
- onlineAfterSeen: this.isOnline(accountId),
3805
- recentInboundReachable: this.hasRecentInboundReachability(accountId),
3806
- activeConnectionKey: this.activeConnectionByAccount.get(accountId) || null,
3807
- activeConnections: Array.from(this.connections.values())
3808
- .filter((c) => c.accountId === accountId)
3809
- .map((c) => ({
3810
- connId: c.connId,
3811
- clientId: c.clientId,
3812
- connectedAt: c.connectedAt,
3813
- lastSeenAt: c.lastSeenAt,
3814
- })),
3815
- })}`,
5469
+ `lifecycle ${JSON.stringify(
5470
+ buildInboundAcceptedLifecycleDebugInfo({
5471
+ stage: 'accepted',
5472
+ bridge: this.bridgeId,
5473
+ accountId,
5474
+ connId,
5475
+ clientId,
5476
+ outboundReady,
5477
+ preferredForOutbound,
5478
+ inboundOnly,
5479
+ onlineAfterSeen: this.isOnline(accountId),
5480
+ recentInboundReachable: this.hasRecentInboundReachability(accountId),
5481
+ activeConnectionKey: this.activeConnectionByAccount.get(accountId) || null,
5482
+ activeConnections: Array.from(this.connections.values())
5483
+ .filter((c) => c.accountId === accountId)
5484
+ .map((c) => ({
5485
+ connId: c.connId,
5486
+ clientId: c.clientId,
5487
+ connectedAt: c.connectedAt,
5488
+ lastSeenAt: c.lastSeenAt,
5489
+ })),
5490
+ }),
5491
+ )}`,
3816
5492
  { debugOnly: true },
3817
5493
  );
3818
5494
  this.lastInboundAtGlobal = now();
3819
5495
  this.incrementCounter(this.inboundEventsByAccount, accountId);
3820
5496
 
3821
- if (!platform || (!userId && !groupId)) {
3822
- respond(false, { error: 'platform/groupId/userId required' });
3823
- return;
3824
- }
3825
- if (this.markInboundDedupSeen(dedupKey)) {
3826
- respond(true, {
3827
- accepted: true,
3828
- duplicated: true,
3829
- accountId,
3830
- msgId: msgId ?? null,
3831
- });
3832
- return;
3833
- }
3834
-
3835
5497
  const cfg = this.api.runtime.config.current();
3836
- const gate = checkBncrMessageGate({
3837
- parsed,
3838
- cfg,
3839
- account: resolveAccount(cfg, accountId),
3840
- });
3841
- if (!gate.allowed) {
3842
- respond(true, {
3843
- accepted: false,
3844
- accountId,
3845
- msgId: msgId ?? null,
3846
- reason: gate.reason,
3847
- });
3848
- return;
3849
- }
3850
-
3851
5498
  const canonicalAgentId = this.ensureCanonicalAgentId({
3852
5499
  cfg,
3853
5500
  accountId,
3854
5501
  peer,
3855
5502
  channelId: CHANNEL_ID,
3856
5503
  });
3857
- const resolvedRoute = this.api.runtime.channel.routing.resolveAgentRoute({
3858
- cfg,
3859
- channel: CHANNEL_ID,
3860
- accountId,
3861
- peer,
3862
- });
3863
- const baseSessionKey =
3864
- normalizeInboundSessionKey(sessionKeyfromroute, route, canonicalAgentId) ||
3865
- resolvedRoute.sessionKey;
3866
- const taskSessionKey = withTaskSessionKey(baseSessionKey, extracted.taskKey);
3867
- const sessionKey = taskSessionKey || baseSessionKey;
3868
- const inboundText = asString(extracted.text || text || '');
5504
+ const acceptance = this.prepareInboundAcceptance({ parsed, canonicalAgentId });
5505
+ if (!acceptance.ok) {
5506
+ respond(acceptance.status, acceptance.payload);
5507
+ return;
5508
+ }
5509
+
5510
+ const { sessionKey, inboundText, hasMedia } = acceptance;
3869
5511
  this.logInfo(
3870
5512
  'inbound',
3871
5513
  JSON.stringify({
@@ -3878,7 +5520,7 @@ class BncrBridgeRuntime {
3878
5520
  msgType,
3879
5521
  textLen: inboundText.length,
3880
5522
  textPreview: inboundText.slice(0, 120),
3881
- hasMedia: Boolean(mediaBase64 || mediaPathFromTransfer),
5523
+ hasMedia,
3882
5524
  }),
3883
5525
  { debugOnly: true },
3884
5526
  );
@@ -3887,17 +5529,24 @@ class BncrBridgeRuntime {
3887
5529
  route,
3888
5530
  msgType,
3889
5531
  text: inboundText,
3890
- hasMedia: Boolean(mediaBase64 || mediaPathFromTransfer),
5532
+ hasMedia,
3891
5533
  });
3892
5534
 
3893
- respond(true, {
3894
- accepted: true,
5535
+ respond(
5536
+ true,
5537
+ buildInboundResponsePayload({
5538
+ kind: 'accepted',
5539
+ accountId,
5540
+ sessionKey,
5541
+ msgId: msgId ?? null,
5542
+ taskKey: extracted.taskKey ?? null,
5543
+ }),
5544
+ );
5545
+ this.flushPushQueue({
3895
5546
  accountId,
3896
- sessionKey,
3897
- msgId: msgId ?? null,
3898
- taskKey: extracted.taskKey ?? null,
5547
+ trigger: OUTBOUND_FLUSH_TRIGGER.INBOUND,
5548
+ reason: OUTBOUND_FLUSH_REASON.INBOUND_ACCEPTED,
3899
5549
  });
3900
- this.flushPushQueue(accountId);
3901
5550
 
3902
5551
  void dispatchBncrInbound({
3903
5552
  api: this.api,
@@ -3937,30 +5586,37 @@ class BncrBridgeRuntime {
3937
5586
  this.lastOutboundByAccount.get(accountId) ||
3938
5587
  previous?.lastEventAt ||
3939
5588
  null;
3940
- this.logInfo(
5589
+ const healthSig = JSON.stringify({
5590
+ bridge: this.bridgeId,
5591
+ accountId,
5592
+ connected,
5593
+ onlineByConn,
5594
+ recentInboundReachable,
5595
+ activeConnectionKey: this.activeConnectionByAccount.get(accountId) || null,
5596
+ activeConnections: Array.from(this.connections.values())
5597
+ .filter((c) => c.accountId === accountId)
5598
+ .map((c) => ({
5599
+ connId: c.connId,
5600
+ clientId: c.clientId,
5601
+ inboundOnly: c.inboundOnly === true,
5602
+ outboundReady: c.outboundReady === true,
5603
+ preferredForOutbound: c.preferredForOutbound === true,
5604
+ })),
5605
+ });
5606
+ const conns = Array.from(this.connections.values()).filter((c) => c.accountId === accountId).length;
5607
+ this.logInfoDedup(
3941
5608
  'health',
3942
- `status-tick ${JSON.stringify({
3943
- bridge: this.bridgeId,
3944
- accountId,
3945
- connected,
3946
- onlineByConn,
3947
- recentInboundReachable,
3948
- lastActivityAt: this.lastActivityByAccount.get(accountId) || null,
3949
- lastInboundAt: this.lastInboundByAccount.get(accountId) || null,
3950
- lastOutboundAt: this.lastOutboundByAccount.get(accountId) || null,
3951
- chosenLastEventAt: lastActAt,
3952
- activeConnectionKey: this.activeConnectionByAccount.get(accountId) || null,
3953
- activeConnections: Array.from(this.connections.values())
3954
- .filter((c) => c.accountId === accountId)
3955
- .map((c) => ({
3956
- connId: c.connId,
3957
- clientId: c.clientId,
3958
- connectedAt: c.connectedAt,
3959
- lastSeenAt: c.lastSeenAt,
3960
- })),
3961
- })}`,
3962
- { debugOnly: true },
5609
+ `status-tick ${accountId}|changed|${connected ? 'linked' : 'configured'}|onlineByConn=${onlineByConn}|recentInboundReachable=${recentInboundReachable}|conns=${conns}`,
5610
+ {
5611
+ key: `health-status-tick:${accountId}`,
5612
+ sig: healthSig,
5613
+ },
3963
5614
  );
5615
+ this.logInfoDedup('health', `status-tick ${healthSig}`, {
5616
+ key: `health-status-tick-debug:${accountId}`,
5617
+ sig: healthSig,
5618
+ debugOnly: true,
5619
+ });
3964
5620
 
3965
5621
  ctx.setStatus?.({
3966
5622
  ...previous,
@@ -3996,6 +5652,7 @@ class BncrBridgeRuntime {
3996
5652
  `status-worker finished ${JSON.stringify({ bridge: this.bridgeId, accountId, reason })}`,
3997
5653
  { debugOnly: true },
3998
5654
  );
5655
+ this.logInfo('health', `status-worker finished ${accountId}|${reason}`);
3999
5656
  resolve();
4000
5657
  };
4001
5658
 
@@ -4027,38 +5684,72 @@ class BncrBridgeRuntime {
4027
5684
  `status-stop ${JSON.stringify({ bridge: this.bridgeId, accountId, cleared })}`,
4028
5685
  { debugOnly: true },
4029
5686
  );
5687
+ this.logInfo('health', `status-stop ${accountId}|cleared=${cleared}`);
4030
5688
  };
4031
5689
 
4032
- channelSendText = async (ctx: any) => {
4033
- await this.syncDebugFlag();
4034
- const accountId = normalizeAccountId(ctx.accountId);
4035
- const to = asString(ctx.to || '').trim();
4036
-
5690
+ private logChannelSendEntry(args: {
5691
+ kind: 'text' | 'media';
5692
+ accountId: string;
5693
+ to: string;
5694
+ ctx: any;
5695
+ payload: {
5696
+ text: string;
5697
+ mediaUrl: string;
5698
+ mediaUrls?: string[];
5699
+ asVoice?: boolean;
5700
+ audioAsVoice?: boolean;
5701
+ };
5702
+ }) {
4037
5703
  this.logInfo(
4038
5704
  'outbound',
4039
- `send-entry:text ${JSON.stringify({
4040
- accountId,
4041
- to,
4042
- text: asString(ctx?.text || ''),
4043
- mediaUrl: asString(ctx?.mediaUrl || ''),
4044
- sessionKey: asString(ctx?.sessionKey || ''),
4045
- mirrorSessionKey: asString(ctx?.mirror?.sessionKey || ''),
5705
+ `send-entry:${args.kind} ${JSON.stringify({
5706
+ accountId: args.accountId,
5707
+ to: args.to,
5708
+ text: args.payload.text,
5709
+ mediaUrl: args.payload.mediaUrl,
5710
+ mediaUrls: args.payload.mediaUrls,
5711
+ asVoice: args.payload.asVoice,
5712
+ audioAsVoice: args.payload.audioAsVoice,
5713
+ sessionKey: asString(args.ctx?.sessionKey || ''),
5714
+ mirrorSessionKey: asString(args.ctx?.mirror?.sessionKey || ''),
4046
5715
  rawCtx: {
4047
- to: ctx?.to,
4048
- accountId: ctx?.accountId,
4049
- threadId: ctx?.threadId,
4050
- replyToId: ctx?.replyToId,
5716
+ to: args.ctx?.to,
5717
+ accountId: args.ctx?.accountId,
5718
+ threadId: args.ctx?.threadId,
5719
+ replyToId: args.ctx?.replyToId,
4051
5720
  },
4052
5721
  })}`,
4053
5722
  { debugOnly: true },
4054
5723
  );
5724
+ }
5725
+
5726
+ private resolveChannelSendReplyToId(ctx: any) {
5727
+ return asString(ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined;
5728
+ }
5729
+
5730
+ channelSendText = async (ctx: any) => {
5731
+ await this.syncDebugFlag();
5732
+ const accountId = normalizeAccountId(ctx.accountId);
5733
+ const to = asString(ctx.to || '').trim();
5734
+ const replyToId = this.resolveChannelSendReplyToId(ctx);
5735
+
5736
+ this.logChannelSendEntry({
5737
+ kind: 'text',
5738
+ accountId,
5739
+ to,
5740
+ ctx,
5741
+ payload: {
5742
+ text: asString(ctx?.text || ''),
5743
+ mediaUrl: asString(ctx?.mediaUrl || ''),
5744
+ },
5745
+ });
4055
5746
 
4056
5747
  return sendBncrText({
4057
5748
  channelId: CHANNEL_ID,
4058
5749
  accountId,
4059
5750
  to,
4060
5751
  text: asString(ctx.text || ''),
4061
- replyToId: asString(ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined,
5752
+ replyToId,
4062
5753
  mediaLocalRoots: ctx.mediaLocalRoots,
4063
5754
  resolveVerifiedTarget: (to, accountId) => this.resolveVerifiedTarget(to, accountId),
4064
5755
  rememberSessionRoute: (sessionKey, accountId, route) =>
@@ -4074,28 +5765,21 @@ class BncrBridgeRuntime {
4074
5765
  const to = asString(ctx.to || '').trim();
4075
5766
  const asVoice = ctx?.asVoice === true;
4076
5767
  const audioAsVoice = ctx?.audioAsVoice === true;
5768
+ const replyToId = this.resolveChannelSendReplyToId(ctx);
4077
5769
 
4078
- this.logInfo(
4079
- 'outbound',
4080
- `send-entry:media ${JSON.stringify({
4081
- accountId,
4082
- to,
5770
+ this.logChannelSendEntry({
5771
+ kind: 'media',
5772
+ accountId,
5773
+ to,
5774
+ ctx,
5775
+ payload: {
4083
5776
  text: asString(ctx?.text || ''),
4084
5777
  mediaUrl: asString(ctx?.mediaUrl || ''),
4085
5778
  mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
4086
5779
  asVoice,
4087
5780
  audioAsVoice,
4088
- sessionKey: asString(ctx?.sessionKey || ''),
4089
- mirrorSessionKey: asString(ctx?.mirror?.sessionKey || ''),
4090
- rawCtx: {
4091
- to: ctx?.to,
4092
- accountId: ctx?.accountId,
4093
- threadId: ctx?.threadId,
4094
- replyToId: ctx?.replyToId,
4095
- },
4096
- })}`,
4097
- { debugOnly: true },
4098
- );
5781
+ },
5782
+ });
4099
5783
 
4100
5784
  return sendBncrMedia({
4101
5785
  channelId: CHANNEL_ID,
@@ -4103,9 +5787,10 @@ class BncrBridgeRuntime {
4103
5787
  to,
4104
5788
  text: asString(ctx.text || ''),
4105
5789
  mediaUrl: asString(ctx.mediaUrl || ''),
5790
+ mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
4106
5791
  asVoice,
4107
5792
  audioAsVoice,
4108
- replyToId: asString(ctx?.replyToId || ctx?.replyToMessageId || '').trim() || undefined,
5793
+ replyToId,
4109
5794
  mediaLocalRoots: ctx.mediaLocalRoots,
4110
5795
  resolveVerifiedTarget: (to, accountId) => this.resolveVerifiedTarget(to, accountId),
4111
5796
  rememberSessionRoute: (sessionKey, accountId, route) =>
@@ -4375,53 +6060,13 @@ export function createBncrChannelPlugin(getBridge: () => BncrBridgeRuntime) {
4375
6060
  buildAccountSnapshot: async ({ account, runtime }: any) => {
4376
6061
  const runtimeBridge = getBridge();
4377
6062
  const rt = runtime || runtimeBridge.getAccountRuntimeSnapshot(account?.accountId);
4378
- const meta = rt?.meta || {};
4379
-
4380
- const pending = Number(rt?.pending ?? meta.pending ?? 0);
4381
- const deadLetter = Number(rt?.deadLetter ?? meta.deadLetter ?? 0);
4382
- const lastSessionKey = rt?.lastSessionKey ?? meta.lastSessionKey ?? null;
4383
- const lastSessionScope = rt?.lastSessionScope ?? meta.lastSessionScope ?? null;
4384
- const lastSessionAt = rt?.lastSessionAt ?? meta.lastSessionAt ?? null;
4385
- const lastSessionAgo = rt?.lastSessionAgo ?? meta.lastSessionAgo ?? '-';
4386
- const lastActivityAt = rt?.lastActivityAt ?? meta.lastActivityAt ?? null;
4387
- const lastActivityAgo = rt?.lastActivityAgo ?? meta.lastActivityAgo ?? '-';
4388
- const lastInboundAt = rt?.lastInboundAt ?? meta.lastInboundAt ?? null;
4389
- const lastInboundAgo = rt?.lastInboundAgo ?? meta.lastInboundAgo ?? '-';
4390
- const lastOutboundAt = rt?.lastOutboundAt ?? meta.lastOutboundAt ?? null;
4391
- const lastOutboundAgo = rt?.lastOutboundAgo ?? meta.lastOutboundAgo ?? '-';
4392
- const diagnostics = rt?.diagnostics ?? meta.diagnostics ?? null;
4393
- // 右侧状态字段统一:离线时也显示 Status(避免出现 configured 文案)
4394
- const normalizedMode = rt?.mode === 'linked' ? 'linked' : 'Status';
4395
-
4396
- const displayName = resolveDefaultDisplayName(account?.name, account?.accountId);
4397
-
4398
- return {
4399
- accountId: account.accountId,
4400
- // default 名不可隐藏时,统一展示稳定默认值
4401
- name: displayName,
4402
- enabled: account.enabled !== false,
4403
- configured: true,
4404
- linked: Boolean(rt?.connected),
4405
- running: rt?.running ?? false,
4406
- connected: rt?.connected ?? false,
4407
- lastEventAt: rt?.lastEventAt ?? null,
4408
- lastError: rt?.lastError ?? null,
4409
- mode: normalizedMode,
4410
- pending,
4411
- deadLetter,
6063
+ return buildAccountStatusSnapshot({
6064
+ account,
6065
+ runtime: rt,
4412
6066
  healthSummary: runtimeBridge.getStatusHeadline(account?.accountId),
4413
- lastSessionKey,
4414
- lastSessionScope,
4415
- lastSessionAt,
4416
- lastSessionAgo,
4417
- lastActivityAt,
4418
- lastActivityAgo,
4419
- lastInboundAt,
4420
- lastInboundAgo,
4421
- lastOutboundAt,
4422
- lastOutboundAgo,
4423
- diagnostics,
4424
- };
6067
+ // default 名不可隐藏时,统一展示稳定默认值
6068
+ displayName: resolveDefaultDisplayName(account?.name, account?.accountId),
6069
+ });
4425
6070
  },
4426
6071
  resolveAccountState: ({ enabled, configured, account, cfg, runtime }: any) => {
4427
6072
  if (!enabled) return 'disabled';