@xmoxmo/bncr 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/index.js +7 -3
  2. package/index.ts +11 -10
  3. package/openclaw.plugin.json +21 -0
  4. package/package.json +4 -4
  5. package/scripts/check-pack.mjs +112 -22
  6. package/scripts/check-register-drift.mjs +91 -65
  7. package/scripts/selfcheck.mjs +79 -3
  8. package/src/channel.ts +549 -810
  9. package/src/core/accounts.ts +1 -1
  10. package/src/core/connection-capability.ts +2 -2
  11. package/src/core/connection-reachability.ts +112 -1
  12. package/src/core/dead-letter-diagnostics.ts +91 -0
  13. package/src/core/diagnostic-counters.ts +61 -0
  14. package/src/core/diagnostics.ts +9 -5
  15. package/src/core/downlink-health.ts +15 -10
  16. package/src/core/extended-diagnostics.ts +4 -0
  17. package/src/core/file-transfer-payloads.ts +1 -4
  18. package/src/core/logging.ts +98 -0
  19. package/src/core/outbox-entry-builders.ts +15 -2
  20. package/src/core/outbox-file-transfer-bookkeeping.ts +1 -1
  21. package/src/core/outbox-file-transfer-failure.ts +2 -5
  22. package/src/core/outbox-file-transfer-success.ts +1 -4
  23. package/src/core/outbox-text-push-failure.ts +2 -4
  24. package/src/core/outbox-text-push-success.ts +1 -1
  25. package/src/core/persisted-outbox-entry.ts +53 -0
  26. package/src/core/probe.ts +33 -13
  27. package/src/core/register-trace.ts +48 -0
  28. package/src/core/status-meta.ts +77 -0
  29. package/src/core/status.ts +50 -57
  30. package/src/messaging/inbound/commands.ts +42 -94
  31. package/src/messaging/inbound/dispatch.ts +25 -54
  32. package/src/messaging/inbound/last-route.ts +46 -0
  33. package/src/messaging/inbound/native-command.ts +49 -0
  34. package/src/messaging/inbound/native-reply-delivery.ts +43 -0
  35. package/src/messaging/inbound/parse.ts +3 -3
  36. package/src/messaging/inbound/runtime-compat.ts +8 -2
  37. package/src/messaging/outbound/build-send-action.ts +1 -2
  38. package/src/messaging/outbound/diagnostics.ts +221 -2
  39. package/src/messaging/outbound/durable-message-adapter.ts +15 -5
  40. package/src/messaging/outbound/durable-queue-adapter.ts +3 -1
  41. package/src/messaging/outbound/media.ts +2 -1
  42. package/src/messaging/outbound/queue-selectors.ts +19 -6
  43. package/src/messaging/outbound/reasons.ts +2 -0
  44. package/src/messaging/outbound/reply-enqueue.ts +29 -2
  45. package/src/messaging/outbound/reply-target-policy.ts +4 -1
  46. package/src/messaging/outbound/retry-policy.ts +16 -8
  47. package/src/messaging/outbound/send-params.ts +56 -0
  48. package/src/messaging/outbound/session-route.ts +1 -1
  49. package/src/openclaw/reply-runtime.ts +4 -5
  50. package/src/openclaw/routing-runtime.ts +0 -1
  51. package/src/openclaw/runtime-surface.ts +29 -0
  52. package/src/openclaw/sdk-helpers.ts +4 -1
  53. package/src/plugin/gateway-methods.ts +2 -0
  54. package/src/plugin/messaging.ts +2 -9
  55. package/src/plugin/status.ts +15 -5
  56. package/src/runtime/outbound-ack-timeout.ts +73 -0
  57. package/src/runtime/outbound-flags.ts +1 -1
  58. package/src/runtime/outbox-transitions.ts +4 -4
  59. package/src/runtime/register-trace-runtime.ts +102 -0
  60. package/src/runtime/status-snapshots.ts +10 -4
  61. package/src/runtime/status-worker.ts +78 -13
@@ -6,30 +6,29 @@ import {
6
6
  normalizeInboundSessionKey,
7
7
  withTaskSessionKey,
8
8
  } from '../../core/targets.ts';
9
- import { handleBncrNativeCommand } from './commands.ts';
10
9
  import {
11
- buildBncrPromptVisibleContextFacts,
12
- buildBncrStructuredContextFactsFromInboundParts,
13
- } from './context-facts.ts';
14
- import { buildBncrReplyConfig } from './reply-config.ts';
15
- import { resolveBncrChannelInboundRuntime } from './runtime-compat.ts';
16
- import { wrapBncrInboundRecordSessionLabelCorrection } from './session-label.ts';
10
+ readBncrSessionUpdatedAt,
11
+ recordBncrInboundSession,
12
+ resolveBncrInboundSessionStorePath,
13
+ resolveBncrPinnedMainDmOwnerFromAllowlist,
14
+ } from '../../openclaw/inbound-session-runtime.ts';
17
15
  import { saveOpenClawChannelMediaBuffer } from '../../openclaw/media-runtime.ts';
18
16
  import {
19
17
  dispatchOpenClawReplyWithBufferedBlockDispatcher,
20
18
  formatOpenClawAgentEnvelope,
21
19
  resolveOpenClawEnvelopeFormatOptions,
22
20
  } from '../../openclaw/reply-runtime.ts';
21
+ import { resolveOpenClawAgentRoute } from '../../openclaw/routing-runtime.ts';
22
+ import type { OutboundReplyTargetPolicy } from '../outbound/reply-target-policy.ts';
23
+ import { handleBncrNativeCommand } from './commands.ts';
23
24
  import {
24
- resolveOpenClawAgentRoute,
25
- resolveOpenClawInboundLastRouteSessionKey,
26
- } from '../../openclaw/routing-runtime.ts';
27
- import {
28
- readBncrSessionUpdatedAt,
29
- recordBncrInboundSession,
30
- resolveBncrInboundSessionStorePath,
31
- resolveBncrPinnedMainDmOwnerFromAllowlist,
32
- } from '../../openclaw/inbound-session-runtime.ts';
25
+ buildBncrPromptVisibleContextFacts,
26
+ buildBncrStructuredContextFactsFromInboundParts,
27
+ } from './context-facts.ts';
28
+ import { buildBncrInboundRecordUpdateLastRoute } from './last-route.ts';
29
+ import { buildBncrReplyConfig } from './reply-config.ts';
30
+ import { resolveBncrChannelInboundRuntime } from './runtime-compat.ts';
31
+ import { wrapBncrInboundRecordSessionLabelCorrection } from './session-label.ts';
33
32
 
34
33
  type ParsedInbound = ReturnType<typeof import('./parse.ts')['parseBncrInboundParams']>;
35
34
 
@@ -68,10 +67,7 @@ export function estimateBase64DecodedBytes(value: string): number {
68
67
  return Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
69
68
  }
70
69
 
71
- export function assertInboundMediaBase64Size(
72
- value: string,
73
- maxBytes = INBOUND_MEDIA_MAX_BYTES,
74
- ) {
70
+ export function assertInboundMediaBase64Size(value: string, maxBytes = INBOUND_MEDIA_MAX_BYTES) {
75
71
  const estimatedBytes = estimateBase64DecodedBytes(value);
76
72
  if (estimatedBytes > maxBytes) {
77
73
  throw new Error(
@@ -161,7 +157,8 @@ async function prepareBncrInboundSessionContext(args: {
161
157
  groupId,
162
158
  userId,
163
159
  } = parsed;
164
- const { accountId, route, resolvedRoute, baseSessionKey, taskSessionKey, dispatchSessionKey } = resolution;
160
+ const { accountId, route, resolvedRoute, baseSessionKey, taskSessionKey, dispatchSessionKey } =
161
+ resolution;
165
162
 
166
163
  rememberSessionRoute(baseSessionKey, accountId, route);
167
164
  if (taskSessionKey && taskSessionKey !== baseSessionKey) {
@@ -332,36 +329,6 @@ function buildBncrInboundTurnContext(args: {
332
329
  });
333
330
  }
334
331
 
335
- function buildBncrInboundRecordUpdateLastRoute(args: {
336
- channelId: string;
337
- peer: ParsedInbound['peer'];
338
- senderIdForContext: string;
339
- resolution: BncrInboundConversationResolution;
340
- pinnedMainDmOwner: string | null;
341
- }) {
342
- const { channelId, peer, senderIdForContext, resolution, pinnedMainDmOwner } = args;
343
- if (peer.kind !== 'direct') return undefined;
344
-
345
- const sessionKey = resolveOpenClawInboundLastRouteSessionKey({
346
- route: resolution.resolvedRoute,
347
- sessionKey: resolution.dispatchSessionKey,
348
- });
349
-
350
- return {
351
- sessionKey,
352
- channel: channelId,
353
- to: resolution.canonicalTo,
354
- accountId: resolution.accountId,
355
- mainDmOwnerPin:
356
- sessionKey === resolution.resolvedRoute.mainSessionKey && pinnedMainDmOwner
357
- ? {
358
- ownerRecipient: pinnedMainDmOwner,
359
- senderRecipient: senderIdForContext,
360
- }
361
- : undefined,
362
- };
363
- }
364
-
365
332
  function buildBncrInboundReplyRouteFact(
366
333
  resolution: BncrInboundConversationResolution,
367
334
  ): BncrInboundReplyRouteFact {
@@ -388,6 +355,7 @@ export async function dispatchBncrInbound(params: {
388
355
  route: any;
389
356
  payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
390
357
  mediaLocalRoots?: readonly string[];
358
+ replyTargetPolicy?: OutboundReplyTargetPolicy;
391
359
  }) => Promise<void>;
392
360
  setInboundActivity: (accountId: string, at: number) => void;
393
361
  scheduleSave: () => void;
@@ -444,7 +412,7 @@ export async function dispatchBncrInbound(params: {
444
412
  resolution,
445
413
  rememberSessionRoute,
446
414
  });
447
- const { storePath, mediaPath, rawBody, body } = prepared;
415
+ const { storePath, mediaPath, rawBody } = prepared;
448
416
  const replyRouteFact = buildBncrInboundReplyRouteFact(resolution);
449
417
  if (!clientId) {
450
418
  emitBncrLogLine(
@@ -480,9 +448,12 @@ export async function dispatchBncrInbound(params: {
480
448
  : null;
481
449
  const updateLastRoute = buildBncrInboundRecordUpdateLastRoute({
482
450
  channelId,
483
- peer,
451
+ peerKind: peer.kind,
484
452
  senderIdForContext,
485
- resolution,
453
+ accountId: resolution.accountId,
454
+ to: resolution.canonicalTo,
455
+ resolvedRoute: resolution.resolvedRoute,
456
+ sessionKey: resolution.dispatchSessionKey,
486
457
  pinnedMainDmOwner,
487
458
  });
488
459
 
@@ -0,0 +1,46 @@
1
+ import { resolveOpenClawInboundLastRouteSessionKey } from '../../openclaw/routing-runtime.ts';
2
+
3
+ export function buildBncrInboundRecordUpdateLastRoute(args: {
4
+ channelId: string;
5
+ peerKind: 'direct' | 'group';
6
+ senderIdForContext: string;
7
+ accountId: string;
8
+ to: string;
9
+ resolvedRoute: {
10
+ sessionKey: string;
11
+ mainSessionKey?: string;
12
+ };
13
+ sessionKey: string;
14
+ pinnedMainDmOwner: string | null;
15
+ }) {
16
+ const {
17
+ channelId,
18
+ peerKind,
19
+ senderIdForContext,
20
+ accountId,
21
+ to,
22
+ resolvedRoute,
23
+ sessionKey,
24
+ pinnedMainDmOwner,
25
+ } = args;
26
+ if (peerKind !== 'direct') return undefined;
27
+
28
+ const inboundLastRouteSessionKey = resolveOpenClawInboundLastRouteSessionKey({
29
+ route: resolvedRoute,
30
+ sessionKey,
31
+ });
32
+
33
+ return {
34
+ sessionKey: inboundLastRouteSessionKey,
35
+ channel: channelId,
36
+ to,
37
+ accountId,
38
+ mainDmOwnerPin:
39
+ inboundLastRouteSessionKey === resolvedRoute.mainSessionKey && pinnedMainDmOwner
40
+ ? {
41
+ ownerRecipient: pinnedMainDmOwner,
42
+ senderRecipient: senderIdForContext,
43
+ }
44
+ : undefined,
45
+ };
46
+ }
@@ -0,0 +1,49 @@
1
+ export type NativeCommand = {
2
+ command: string;
3
+ raw: string;
4
+ body: string;
5
+ };
6
+
7
+ export type NativeVerboseCommand = {
8
+ handled: true;
9
+ verboseLevel?: 'on' | 'off' | 'full';
10
+ text: string;
11
+ };
12
+
13
+ export function parseBncrNativeCommand(text: string): NativeCommand | null {
14
+ const raw = String(text || '').trim();
15
+ if (!raw.startsWith('/')) return null;
16
+ const match = raw.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/i);
17
+ if (!match) return null;
18
+
19
+ const command = String(match[1] || '')
20
+ .trim()
21
+ .toLowerCase();
22
+ if (!command) return null;
23
+
24
+ const rest = String(match[2] || '').trim();
25
+ const body = command === 'help' ? ['/commands', rest].filter(Boolean).join(' ') : raw;
26
+ return { command, raw, body };
27
+ }
28
+
29
+ export function resolveBncrNativeVerboseCommand(
30
+ command: NativeCommand,
31
+ ): NativeVerboseCommand | null {
32
+ if (command.command !== 'verbose') return null;
33
+ const rawLevel = String(command.raw.slice('/verbose'.length) || '')
34
+ .trim()
35
+ .toLowerCase();
36
+ if (!rawLevel || rawLevel === 'status') {
37
+ return { handled: true, text: 'Current verbose level is unchanged.' };
38
+ }
39
+ if (rawLevel === 'on')
40
+ return { handled: true, verboseLevel: 'on', text: 'Verbose logging enabled.' };
41
+ if (rawLevel === 'off')
42
+ return { handled: true, verboseLevel: 'off', text: 'Verbose logging disabled.' };
43
+ if (rawLevel === 'full')
44
+ return { handled: true, verboseLevel: 'full', text: 'Verbose logging set to full.' };
45
+ return {
46
+ handled: true,
47
+ text: `Unrecognized verbose level "${rawLevel}". Valid levels: off, on, full.`,
48
+ };
49
+ }
@@ -0,0 +1,43 @@
1
+ type BncrNativeReplyKind = 'tool' | 'block' | 'final';
2
+
3
+ export type BncrNativeReplyDeliveryPayload = {
4
+ text?: string;
5
+ mediaUrl?: string;
6
+ mediaUrls?: string[];
7
+ audioAsVoice?: boolean;
8
+ replyToId?: string;
9
+ };
10
+
11
+ export function buildBncrNativeReplyDeliveryPayload(args: {
12
+ payload?: {
13
+ text?: string;
14
+ mediaUrl?: string;
15
+ mediaUrls?: string[];
16
+ audioAsVoice?: boolean;
17
+ };
18
+ kind?: BncrNativeReplyKind;
19
+ effectiveReply: {
20
+ blockStreaming: boolean;
21
+ allowTool: boolean;
22
+ };
23
+ msgId?: string;
24
+ }): BncrNativeReplyDeliveryPayload | null {
25
+ const { payload, kind, effectiveReply, msgId } = args;
26
+ const shouldForwardTool = effectiveReply.blockStreaming && effectiveReply.allowTool;
27
+
28
+ if (kind === 'tool' && !shouldForwardTool) {
29
+ return null;
30
+ }
31
+
32
+ const hasPayload = Boolean(
33
+ payload?.text ||
34
+ payload?.mediaUrl ||
35
+ (Array.isArray(payload?.mediaUrls) && payload.mediaUrls.length > 0),
36
+ );
37
+ if (!hasPayload) return null;
38
+
39
+ return {
40
+ ...payload,
41
+ replyToId: msgId || undefined,
42
+ };
43
+ }
@@ -49,9 +49,9 @@ export function parseBncrInboundParams(params: any) {
49
49
  const groupId = asString(params?.groupId || '0').trim() || '0';
50
50
  const userId = asString(params?.userId || '').trim();
51
51
  const sessionKeyfromroute = asString(params?.sessionKey || '').trim();
52
- const providedOriginatingTo = asString(
53
- params?.originatingTo || params?.providedOriginatingTo || params?.to || '',
54
- ).trim() || undefined;
52
+ const providedOriginatingTo =
53
+ asString(params?.originatingTo || params?.providedOriginatingTo || params?.to || '').trim() ||
54
+ undefined;
55
55
  const clientId = asString(params?.clientId || '').trim() || undefined;
56
56
 
57
57
  const route: BncrRoute = {
@@ -20,8 +20,14 @@ export function resolveBncrChannelInboundRuntime(api: any): ChannelRuntimeCompat
20
20
  if (legacyTurnRuntime?.buildContext && legacyTurnRuntime?.run) {
21
21
  if (!warnedLegacyTurnRuntime) {
22
22
  warnedLegacyTurnRuntime = true;
23
- const channelRuntimeKeys = Object.keys(channelRuntime ?? {}).sort().join(',') || 'none';
24
- const inboundRuntimeKeys = Object.keys(inboundRuntime ?? {}).sort().join(',') || 'none';
23
+ const channelRuntimeKeys =
24
+ Object.keys(channelRuntime ?? {})
25
+ .sort()
26
+ .join(',') || 'none';
27
+ const inboundRuntimeKeys =
28
+ Object.keys(inboundRuntime ?? {})
29
+ .sort()
30
+ .join(',') || 'none';
25
31
  emitBncrLogLine(
26
32
  'warn',
27
33
  `[bncr] inbound runtime fallback=turn|preferred=inbound|channelKeys=${channelRuntimeKeys}|inboundKeys=${inboundRuntimeKeys}`,
@@ -56,8 +56,7 @@ export function buildBncrMessageAction(input: MinimalBncrSendInput): BuiltBncrMe
56
56
 
57
57
  const channel = asString(input.channel || 'bncr').trim() || 'bncr';
58
58
  const action = asString(input.action || 'send').trim() || 'send';
59
- const idempotencyKey =
60
- asString(input.idempotencyKey || '').trim() || `bncr-${randomUUID()}`;
59
+ const idempotencyKey = asString(input.idempotencyKey || '').trim() || `bncr-${randomUUID()}`;
61
60
  const accountId =
62
61
  asString(pickFirstString(paramsObj.accountId, input.accountId) || '').trim() || undefined;
63
62
 
@@ -1,7 +1,226 @@
1
- import type { BncrConnection } from '../../core/types.ts';
1
+ import type { BncrConnection, OutboxEntry } from '../../core/types.ts';
2
2
  import { OUTBOUND_TERMINAL_REASON, type OutboundScheduleSource } from './reasons.ts';
3
3
  import type { RetryRerouteDecision } from './retry-policy.ts';
4
4
 
5
+ type OutboxIncidentSummaryInput = {
6
+ pending: number;
7
+ oldestPendingAt?: number | null;
8
+ lastAttemptAt?: number | null;
9
+ lastPushAt?: number | null;
10
+ lastPushError?: string | null;
11
+ hasGatewayContext: boolean;
12
+ activeOutboundConnection: boolean;
13
+ activeOutboundConnectionCount: number;
14
+ prePushGuardSkipCount?: number;
15
+ lastPrePushGuardSkipAt?: number | null;
16
+ lastPrePushGuardSkipReason?: string | null;
17
+ lastAckQueueLatencyMs?: number | null;
18
+ lastAckPushLatencyMs?: number | null;
19
+ lastLateAckQueueLatencyMs?: number | null;
20
+ lastLateAckPushLatencyMs?: number | null;
21
+ lastLateAckOkAt?: number | null;
22
+ adaptiveAckTimeoutMs?: number | null;
23
+ adaptiveAckTimeoutReason?: string | null;
24
+ nowMs?: number;
25
+ };
26
+
27
+ function finiteNumberOrNull(value: unknown): number | null {
28
+ if (value == null) return null;
29
+ const n = Number(value);
30
+ return Number.isFinite(n) ? n : null;
31
+ }
32
+
33
+ function positiveAgeMs(nowMs: number, at: unknown): number | null {
34
+ const n = finiteNumberOrNull(at);
35
+ if (n === null || n < 0) return null;
36
+ return Math.max(0, nowMs - n);
37
+ }
38
+
39
+ export function buildOutboxIncidentSummary(input: OutboxIncidentSummaryInput) {
40
+ const pending = Math.max(0, Math.floor(finiteNumberOrNull(input.pending) || 0));
41
+ const nowMs = finiteNumberOrNull(input.nowMs) || Date.now();
42
+ const lastPushError =
43
+ typeof input.lastPushError === 'string' && input.lastPushError ? input.lastPushError : null;
44
+ const lastPrePushGuardSkipReason =
45
+ typeof input.lastPrePushGuardSkipReason === 'string' && input.lastPrePushGuardSkipReason
46
+ ? input.lastPrePushGuardSkipReason
47
+ : null;
48
+ const oldestPendingAgeMs = positiveAgeMs(nowMs, input.oldestPendingAt);
49
+ const lastLateAckAgeMs = positiveAgeMs(nowMs, input.lastLateAckOkAt);
50
+ const adaptiveAckTimeoutReason =
51
+ typeof input.adaptiveAckTimeoutReason === 'string' && input.adaptiveAckTimeoutReason
52
+ ? input.adaptiveAckTimeoutReason
53
+ : null;
54
+ const hasRecentLateAck = lastLateAckAgeMs !== null && lastLateAckAgeMs <= 3_600_000;
55
+ const hasActiveAdaptiveAck =
56
+ adaptiveAckTimeoutReason !== null &&
57
+ ![
58
+ 'no-timeout-evidence',
59
+ 'no-late-ack-evidence',
60
+ 'missing-latency',
61
+ 'late-ack-expired',
62
+ 'recovered',
63
+ ].includes(adaptiveAckTimeoutReason);
64
+
65
+ let type = 'none';
66
+ let severity: 'ok' | 'warning' | 'critical' = 'ok';
67
+ let recommendedAction = 'none';
68
+
69
+ if (pending > 0 && !input.hasGatewayContext) {
70
+ type = 'no-gateway-context';
71
+ severity = 'critical';
72
+ recommendedAction = 'check-channel-message-runtime-context';
73
+ } else if (pending > 0 && !input.activeOutboundConnection) {
74
+ type = 'no-active-outbound-connection';
75
+ severity = 'critical';
76
+ recommendedAction = 'reconnect-bncr-client';
77
+ } else if (pending > 0 && lastPrePushGuardSkipReason) {
78
+ type = lastPrePushGuardSkipReason;
79
+ severity = 'warning';
80
+ recommendedAction = 'inspect-pre-push-guard';
81
+ } else if (pending > 0 && lastPushError) {
82
+ type =
83
+ lastPushError.includes('ack') || lastPushError.includes('timeout')
84
+ ? 'ack-timeout'
85
+ : lastPushError;
86
+ severity = type === 'ack-timeout' ? 'critical' : 'warning';
87
+ recommendedAction = 'inspect-ack-and-route-state';
88
+ } else if (pending > 0 && oldestPendingAgeMs !== null && oldestPendingAgeMs >= 120_000) {
89
+ type = 'outbox-backlog';
90
+ severity = 'warning';
91
+ recommendedAction = 'inspect-outbox-drain';
92
+ } else if (pending > 0) {
93
+ type = 'pending-outbox';
94
+ severity = 'warning';
95
+ recommendedAction = 'inspect-outbox-drain';
96
+ } else if (hasRecentLateAck || hasActiveAdaptiveAck) {
97
+ type = 'slow-or-late-ack';
98
+ severity = 'warning';
99
+ recommendedAction = 'inspect-ack-latency';
100
+ }
101
+
102
+ return {
103
+ active: type !== 'none',
104
+ type,
105
+ severity,
106
+ recommendedAction,
107
+ pending,
108
+ oldestPendingAgeMs,
109
+ lastAttemptAgeMs: positiveAgeMs(nowMs, input.lastAttemptAt),
110
+ lastPushAgeMs: positiveAgeMs(nowMs, input.lastPushAt),
111
+ lastPushError,
112
+ hasGatewayContext: input.hasGatewayContext,
113
+ activeOutboundConnection: input.activeOutboundConnection,
114
+ activeOutboundConnectionCount: Math.max(
115
+ 0,
116
+ Math.floor(finiteNumberOrNull(input.activeOutboundConnectionCount) || 0),
117
+ ),
118
+ prePushGuardSkipCount: Math.max(
119
+ 0,
120
+ Math.floor(finiteNumberOrNull(input.prePushGuardSkipCount) || 0),
121
+ ),
122
+ lastPrePushGuardSkipAgeMs: positiveAgeMs(nowMs, input.lastPrePushGuardSkipAt),
123
+ lastPrePushGuardSkipReason,
124
+ ack: {
125
+ lastQueueLatencyMs: finiteNumberOrNull(input.lastAckQueueLatencyMs),
126
+ lastPushLatencyMs: finiteNumberOrNull(input.lastAckPushLatencyMs),
127
+ lastLateQueueLatencyMs: finiteNumberOrNull(input.lastLateAckQueueLatencyMs),
128
+ lastLatePushLatencyMs: finiteNumberOrNull(input.lastLateAckPushLatencyMs),
129
+ lastLateAckAgeMs,
130
+ adaptiveTimeoutMs: finiteNumberOrNull(input.adaptiveAckTimeoutMs),
131
+ adaptiveTimeoutReason: adaptiveAckTimeoutReason,
132
+ },
133
+ };
134
+ }
135
+
136
+ export function buildExtendedOutboundDiagnostics(input: {
137
+ outbox: Record<string, any>;
138
+ enqueueCount: number;
139
+ lastEnqueueAt?: number | null;
140
+ prePushGuardSkipCount: number;
141
+ lastPrePushGuardSkipAt?: number | null;
142
+ lastPrePushGuardSkipReason?: string | null;
143
+ hasGatewayContext: boolean;
144
+ lastGatewayContextAt?: number | null;
145
+ ackObservability: Record<string, any>;
146
+ nowMs?: number;
147
+ }) {
148
+ const lastEnqueueAt = finiteNumberOrNull(input.lastEnqueueAt);
149
+ const lastPrePushGuardSkipAt = finiteNumberOrNull(input.lastPrePushGuardSkipAt);
150
+ const lastGatewayContextAt = finiteNumberOrNull(input.lastGatewayContextAt);
151
+ return {
152
+ ...input.outbox,
153
+ enqueueCount: input.enqueueCount,
154
+ lastEnqueueAt,
155
+ prePushGuardSkipCount: input.prePushGuardSkipCount,
156
+ lastPrePushGuardSkipAt,
157
+ lastPrePushGuardSkipReason: input.lastPrePushGuardSkipReason || null,
158
+ hasGatewayContext: input.hasGatewayContext,
159
+ lastGatewayContextAt,
160
+ incident: buildOutboxIncidentSummary({
161
+ ...input.outbox,
162
+ hasGatewayContext: input.hasGatewayContext,
163
+ prePushGuardSkipCount: input.prePushGuardSkipCount,
164
+ lastPrePushGuardSkipAt,
165
+ lastPrePushGuardSkipReason: input.lastPrePushGuardSkipReason || null,
166
+ lastAckQueueLatencyMs: input.ackObservability.lastAckQueueLatencyMs,
167
+ lastAckPushLatencyMs: input.ackObservability.lastAckPushLatencyMs,
168
+ lastLateAckQueueLatencyMs: input.ackObservability.lastLateAckQueueLatencyMs,
169
+ lastLateAckPushLatencyMs: input.ackObservability.lastLateAckPushLatencyMs,
170
+ lastLateAckOkAt: input.ackObservability.lastLateAckOkAt,
171
+ adaptiveAckTimeoutMs: input.ackObservability.currentAckTimeoutMs,
172
+ adaptiveAckTimeoutReason: input.ackObservability.recommendedAckTimeoutReason,
173
+ nowMs: input.nowMs,
174
+ }),
175
+ };
176
+ }
177
+
178
+ export function buildOutboxQueueDiagnostics(args: {
179
+ accountId: string;
180
+ outboxEntries: Iterable<OutboxEntry>;
181
+ pendingAllAccounts: number;
182
+ pushConnIds: Iterable<string>;
183
+ }) {
184
+ const accountEntries = Array.from(args.outboxEntries).filter(
185
+ (entry) => entry.accountId === args.accountId,
186
+ );
187
+ let oldestPendingAt: number | null = null;
188
+ let newestPendingAt: number | null = null;
189
+ let lastAttemptAt: number | null = null;
190
+ let lastPushAt: number | null = null;
191
+ let lastError: string | null = null;
192
+
193
+ for (const entry of accountEntries) {
194
+ const createdAt = Number(entry.createdAt);
195
+ if (Number.isFinite(createdAt)) {
196
+ oldestPendingAt = oldestPendingAt === null ? createdAt : Math.min(oldestPendingAt, createdAt);
197
+ newestPendingAt = newestPendingAt === null ? createdAt : Math.max(newestPendingAt, createdAt);
198
+ }
199
+ const attemptAt = Number(entry.lastAttemptAt);
200
+ if (Number.isFinite(attemptAt) && (lastAttemptAt === null || attemptAt > lastAttemptAt)) {
201
+ lastAttemptAt = attemptAt;
202
+ }
203
+ const pushAt = Number(entry.lastPushAt);
204
+ if (Number.isFinite(pushAt) && (lastPushAt === null || pushAt > lastPushAt)) {
205
+ lastPushAt = pushAt;
206
+ lastError = entry.lastError || null;
207
+ }
208
+ }
209
+
210
+ const pushConnIds = Array.from(args.pushConnIds);
211
+ return {
212
+ pending: accountEntries.length,
213
+ pendingAllAccounts: args.pendingAllAccounts,
214
+ oldestPendingAt,
215
+ newestPendingAt,
216
+ lastAttemptAt,
217
+ lastPushAt,
218
+ lastPushError: lastError,
219
+ activeOutboundConnection: pushConnIds.length > 0,
220
+ activeOutboundConnectionCount: pushConnIds.length,
221
+ };
222
+ }
223
+
5
224
  export function buildOutboxScheduleDebugInfo(args: {
6
225
  bridgeId: string;
7
226
  accountId?: string | null;
@@ -187,7 +406,7 @@ export function buildOutboxDrainStuckDebugInfo(args: {
187
406
  outboxSize: args.outboxSize,
188
407
  pending: args.pending,
189
408
  runningMs: args.runningMs,
190
- runningSince: args.runningSince || null,
409
+ runningSince: args.runningSince ?? null,
191
410
  hasGatewayContext: args.hasGatewayContext,
192
411
  activeConnectionCount: args.activeConnectionCount,
193
412
  waiters: {
@@ -1,4 +1,3 @@
1
- import { defineChannelMessageAdapter } from 'openclaw/plugin-sdk/channel-message';
2
1
  import type {
3
2
  ChannelMessageAdapterShape,
4
3
  ChannelMessageSendMediaContext,
@@ -6,15 +5,23 @@ import type {
6
5
  ChannelMessageSendResult,
7
6
  ChannelMessageSendTextContext,
8
7
  } from 'openclaw/plugin-sdk/channel-message';
8
+ import { defineChannelMessageAdapter } from 'openclaw/plugin-sdk/channel-message';
9
9
 
10
- import { buildFileTransferOutboxEntry, buildTextOutboxEntry } from '../../core/outbox-entry-builders.ts';
10
+ import {
11
+ buildFileTransferOutboxEntry,
12
+ buildTextOutboxEntry,
13
+ } from '../../core/outbox-entry-builders.ts';
11
14
  import type { BncrRoute, OutboxEntry } from '../../core/types.ts';
12
15
  import { buildBncrDurableQueuedResult } from './durable-queue-adapter.ts';
13
16
 
14
17
  export type BncrDurableMessageQueuedAdapterDeps<TConfig = unknown> = {
15
18
  enqueueText: (ctx: ChannelMessageSendTextContext<TConfig>) => Promise<OutboxEntry> | OutboxEntry;
16
- enqueueMedia?: (ctx: ChannelMessageSendMediaContext<TConfig>) => Promise<OutboxEntry> | OutboxEntry;
17
- enqueuePayload?: (ctx: ChannelMessageSendPayloadContext<TConfig>) => Promise<OutboxEntry> | OutboxEntry;
19
+ enqueueMedia?: (
20
+ ctx: ChannelMessageSendMediaContext<TConfig>,
21
+ ) => Promise<OutboxEntry> | OutboxEntry;
22
+ enqueuePayload?: (
23
+ ctx: ChannelMessageSendPayloadContext<TConfig>,
24
+ ) => Promise<OutboxEntry> | OutboxEntry;
18
25
  now?: () => number;
19
26
  };
20
27
 
@@ -97,7 +104,10 @@ export function createBncrDurableMessageQueuedAdapterFromBuilders<TConfig = unkn
97
104
  });
98
105
  }
99
106
 
100
- function toChannelMessageSendResult(entry: OutboxEntry | undefined, now?: () => number): ChannelMessageSendResult {
107
+ function toChannelMessageSendResult(
108
+ entry: OutboxEntry | undefined,
109
+ now?: () => number,
110
+ ): ChannelMessageSendResult {
101
111
  if (!entry) throw new Error('bncr durable message adapter did not receive an outbox entry');
102
112
  const queued = buildBncrDurableQueuedResult({ entry, sentAt: now?.() });
103
113
  return {
@@ -74,7 +74,9 @@ export function buildBncrDurableQueuedResult(args: {
74
74
  }): BncrDurableQueuedResult {
75
75
  const sentAt = Number.isFinite(args.sentAt) ? Number(args.sentAt) : args.entry.createdAt;
76
76
  const platformMessageId = args.entry.messageId;
77
- const replyToId = normalizeOutboundReplyToId({ replyToId: args.replyToId ?? extractReplyToId(args.entry) }) || undefined;
77
+ const replyToId =
78
+ normalizeOutboundReplyToId({ replyToId: args.replyToId ?? extractReplyToId(args.entry) }) ||
79
+ undefined;
78
80
  const chatId = formatQueuedReceiptChatId(args.entry.route);
79
81
  const meta: BncrDurableQueuedReceiptMeta = {
80
82
  status: 'accepted',
@@ -66,7 +66,8 @@ export function buildBncrMediaOutboundFrame(params: {
66
66
  messageId: params.messageId,
67
67
  idempotencyKey: params.messageId,
68
68
  sessionKey: params.sessionKey,
69
- replyToId: normalizeOutboundReplyToId({ kind: params.kind, replyToId: params.replyToId }) || undefined,
69
+ replyToId:
70
+ normalizeOutboundReplyToId({ kind: params.kind, replyToId: params.replyToId }) || undefined,
70
71
  message: {
71
72
  platform: params.route.platform,
72
73
  groupId: params.route.groupId,
@@ -34,7 +34,10 @@ export function computeOutboxRetryWait(nextAttemptAt: number, nowMs: number): nu
34
34
  return Math.max(0, finiteNumberOr(nextAttemptAt, 0) - finiteNumberOr(nowMs, 0));
35
35
  }
36
36
 
37
- export function updateMinOutboxDelay(currentDelay: number | null, candidateDelay: number | null): number | null {
37
+ export function updateMinOutboxDelay(
38
+ currentDelay: number | null,
39
+ candidateDelay: number | null,
40
+ ): number | null {
38
41
  if (candidateDelay == null) return currentDelay;
39
42
  return currentDelay == null ? candidateDelay : Math.min(currentDelay, candidateDelay);
40
43
  }
@@ -51,7 +54,9 @@ export function selectOutboxTargetAccounts(args: {
51
54
  const filterAcc = args.accountId ? args.normalizeAccountId(args.accountId) : null;
52
55
  if (filterAcc) return [filterAcc];
53
56
  return Array.from(
54
- new Set(Array.from(args.outboxEntries).map((entry) => args.normalizeAccountId(entry.accountId))),
57
+ new Set(
58
+ Array.from(args.outboxEntries).map((entry) => args.normalizeAccountId(entry.accountId)),
59
+ ),
55
60
  );
56
61
  }
57
62
 
@@ -112,7 +117,11 @@ export function selectOutboxFileTransferRouteCandidates(args: {
112
117
  );
113
118
  const ownerConnId =
114
119
  args.ownerConnId && !attemptedConnIds.has(args.ownerConnId) ? args.ownerConnId : undefined;
115
- let connIds = ownerConnId ? [ownerConnId] : filteredCandidates.length > 0 ? filteredCandidates : routeCandidates;
120
+ let connIds = ownerConnId
121
+ ? [ownerConnId]
122
+ : filteredCandidates.length > 0
123
+ ? filteredCandidates
124
+ : routeCandidates;
116
125
  let routeReason: OutboxFileTransferRouteSelection['routeReason'] = ownerConnId
117
126
  ? 'owner'
118
127
  : connIds.length > 0
@@ -128,7 +137,8 @@ export function selectOutboxFileTransferRouteCandidates(args: {
128
137
  const filteredRecentInboundConnIds = recentInboundConnIds.filter(
129
138
  (connId) => !attemptedConnIds.has(connId),
130
139
  );
131
- connIds = filteredRecentInboundConnIds.length > 0 ? filteredRecentInboundConnIds : recentInboundConnIds;
140
+ connIds =
141
+ filteredRecentInboundConnIds.length > 0 ? filteredRecentInboundConnIds : recentInboundConnIds;
132
142
  routeReason = connIds.length > 0 ? 'recent-inbound-fallback' : 'none';
133
143
  }
134
144
 
@@ -154,9 +164,12 @@ export function selectOutboxRouteCandidates(args: {
154
164
  const revalidatedCandidates = routeCandidates.filter(
155
165
  (connId) => attemptedConnIds.has(connId) && args.isRevalidatedAttemptedConn(connId),
156
166
  );
157
- const preferredCandidates = unattemptedCandidates.length > 0 ? unattemptedCandidates : routeCandidates;
167
+ const preferredCandidates =
168
+ unattemptedCandidates.length > 0 ? unattemptedCandidates : routeCandidates;
158
169
  const ownerConnId =
159
- args.ownerConnId && preferredCandidates.includes(args.ownerConnId) ? args.ownerConnId : undefined;
170
+ args.ownerConnId && preferredCandidates.includes(args.ownerConnId)
171
+ ? args.ownerConnId
172
+ : undefined;
160
173
  let connIds = ownerConnId ? [ownerConnId] : preferredCandidates;
161
174
  let routeReason: OutboxRouteSelection['routeReason'] = ownerConnId
162
175
  ? 'owner'