@xmoxmo/bncr 0.3.6 → 0.3.8

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 (165) hide show
  1. package/README.md +5 -0
  2. package/dist/index.js +28 -5
  3. package/index.ts +55 -721
  4. package/openclaw.plugin.json +1 -0
  5. package/package.json +8 -4
  6. package/scripts/check-pack.mjs +93 -18
  7. package/scripts/check-register-drift.mjs +35 -13
  8. package/scripts/selfcheck.mjs +80 -11
  9. package/src/bootstrap/channel-plugin-runtime.ts +81 -0
  10. package/src/bootstrap/cli.ts +97 -0
  11. package/src/bootstrap/register-runtime-gateway.ts +129 -0
  12. package/src/bootstrap/register-runtime-helpers.ts +140 -0
  13. package/src/bootstrap/register-runtime-singleton.ts +137 -0
  14. package/src/bootstrap/register-runtime.ts +201 -0
  15. package/src/bootstrap/runtime-discovery.ts +187 -0
  16. package/src/bootstrap/runtime-loader.ts +54 -0
  17. package/src/channel.ts +1590 -4967
  18. package/src/core/accounts.ts +23 -4
  19. package/src/core/dead-letter-diagnostics.ts +37 -5
  20. package/src/core/diagnostics.ts +31 -15
  21. package/src/core/downlink-health.ts +3 -11
  22. package/src/core/extended-diagnostics.ts +78 -36
  23. package/src/core/file-transfer-payloads.ts +1 -1
  24. package/src/core/logging.ts +1 -0
  25. package/src/core/outbox-enqueue.ts +13 -2
  26. package/src/core/outbox-entry-builders.ts +2 -0
  27. package/src/core/outbox-summary.ts +75 -3
  28. package/src/core/permissions.ts +15 -2
  29. package/src/core/persisted-outbox-entry.ts +21 -6
  30. package/src/core/policy.ts +45 -4
  31. package/src/core/probe.ts +3 -15
  32. package/src/core/register-trace.ts +3 -3
  33. package/src/core/status.ts +43 -4
  34. package/src/core/targets.ts +216 -205
  35. package/src/core/types.ts +221 -0
  36. package/src/core/value-sanitize.ts +29 -0
  37. package/src/messaging/inbound/commands.ts +147 -172
  38. package/src/messaging/inbound/context-facts.ts +4 -2
  39. package/src/messaging/inbound/contracts.ts +70 -0
  40. package/src/messaging/inbound/dispatch-prep.ts +303 -0
  41. package/src/messaging/inbound/dispatch.ts +49 -462
  42. package/src/messaging/inbound/gate.ts +18 -5
  43. package/src/messaging/inbound/last-route.ts +10 -4
  44. package/src/messaging/inbound/media-url-download.ts +109 -0
  45. package/src/messaging/inbound/native-command-runtime.ts +225 -0
  46. package/src/messaging/inbound/parse.ts +2 -1
  47. package/src/messaging/inbound/remote-media.ts +49 -0
  48. package/src/messaging/inbound/reply-config.ts +16 -4
  49. package/src/messaging/inbound/reply-dispatch.ts +162 -0
  50. package/src/messaging/inbound/runtime-compat.ts +31 -10
  51. package/src/messaging/inbound/session-label.ts +15 -7
  52. package/src/messaging/inbound/turn-context.ts +131 -0
  53. package/src/messaging/outbound/actions.ts +24 -10
  54. package/src/messaging/outbound/diagnostics-debug-builders.ts +365 -0
  55. package/src/messaging/outbound/diagnostics.ts +31 -355
  56. package/src/messaging/outbound/durable-message-adapter.ts +20 -16
  57. package/src/messaging/outbound/durable-queue-adapter.ts +20 -7
  58. package/src/messaging/outbound/media.ts +24 -13
  59. package/src/messaging/outbound/reply-enqueue-media.ts +181 -0
  60. package/src/messaging/outbound/reply-enqueue.ts +46 -155
  61. package/src/messaging/outbound/send-params.ts +3 -0
  62. package/src/messaging/outbound/send.ts +19 -10
  63. package/src/messaging/outbound/session-route.ts +18 -3
  64. package/src/openclaw/channel-runtime-contracts.ts +76 -0
  65. package/src/openclaw/config-runtime.ts +13 -7
  66. package/src/openclaw/inbound-session-runtime.ts +7 -3
  67. package/src/openclaw/ingress-runtime.ts +17 -27
  68. package/src/openclaw/reply-runtime.ts +54 -59
  69. package/src/openclaw/routing-runtime.ts +35 -18
  70. package/src/openclaw/runtime-surface.ts +156 -12
  71. package/src/openclaw/sdk-helpers.ts +8 -1
  72. package/src/openclaw/session-route-runtime.ts +12 -12
  73. package/src/plugin/ack-outbox-runtime-group.ts +264 -0
  74. package/src/plugin/bridge-ack-facade.ts +137 -0
  75. package/src/plugin/bridge-connection-facade.ts +111 -0
  76. package/src/plugin/bridge-diagnostics-facade.ts +23 -0
  77. package/src/plugin/bridge-drain-facade.ts +98 -0
  78. package/src/plugin/bridge-extended-diagnostics-facade.ts +149 -0
  79. package/src/plugin/bridge-file-transfer-push-facade.ts +140 -0
  80. package/src/plugin/bridge-lifecycle.ts +156 -0
  81. package/src/plugin/bridge-media-facade.ts +241 -0
  82. package/src/plugin/bridge-outbox-facade.ts +182 -0
  83. package/src/plugin/bridge-runtime-helpers.ts +266 -0
  84. package/src/plugin/bridge-runtime-snapshots.ts +104 -0
  85. package/src/plugin/bridge-runtime-surface-facade.ts +8 -0
  86. package/src/plugin/bridge-status-facade.ts +76 -0
  87. package/src/plugin/bridge-status-worker-facade.ts +72 -0
  88. package/src/plugin/bridge-support-runtime.ts +137 -0
  89. package/src/plugin/bridge-surface-handlers-group.ts +242 -0
  90. package/src/plugin/bridge-surface-helpers.ts +28 -0
  91. package/src/plugin/capabilities.ts +1 -3
  92. package/src/plugin/channel-components.ts +289 -0
  93. package/src/plugin/channel-inbound-helpers.ts +149 -0
  94. package/src/plugin/channel-plugin-bridge-group.ts +129 -0
  95. package/src/plugin/channel-plugin-surface-group.ts +202 -0
  96. package/src/plugin/channel-runtime-builders-delivery.ts +513 -0
  97. package/src/plugin/channel-runtime-builders-status.ts +331 -0
  98. package/src/plugin/channel-runtime-builders.ts +25 -0
  99. package/src/plugin/channel-runtime-constants.ts +40 -0
  100. package/src/plugin/channel-runtime-types.ts +146 -0
  101. package/src/plugin/channel-send-runtime-group.ts +37 -0
  102. package/src/plugin/channel-send.ts +226 -0
  103. package/src/plugin/channel-utils.ts +102 -0
  104. package/src/plugin/config.ts +24 -3
  105. package/src/plugin/connection-handlers-helpers.ts +254 -0
  106. package/src/plugin/connection-handlers.ts +440 -0
  107. package/src/plugin/connection-state-helpers.ts +159 -0
  108. package/src/plugin/connection-state-runtime-group.ts +51 -0
  109. package/src/plugin/connection-state.ts +527 -0
  110. package/src/plugin/diagnostics-handlers.ts +211 -0
  111. package/src/plugin/error-message.ts +15 -0
  112. package/src/plugin/file-ack-runtime.ts +284 -0
  113. package/src/plugin/file-inbound-abort.ts +112 -0
  114. package/src/plugin/file-inbound-chunk.ts +146 -0
  115. package/src/plugin/file-inbound-complete.ts +153 -0
  116. package/src/plugin/file-inbound-handlers.ts +19 -0
  117. package/src/plugin/file-inbound-init.ts +122 -0
  118. package/src/plugin/file-inbound-runtime.ts +51 -0
  119. package/src/plugin/file-inbound-state.ts +62 -0
  120. package/src/plugin/file-transfer-logs.ts +227 -0
  121. package/src/plugin/file-transfer-orchestrator-chunk.ts +135 -0
  122. package/src/plugin/file-transfer-orchestrator.ts +304 -0
  123. package/src/plugin/file-transfer-runtime-group.ts +102 -0
  124. package/src/plugin/file-transfer-send.ts +89 -0
  125. package/src/plugin/file-transfer-setup.ts +206 -0
  126. package/src/plugin/gateway-event-context.ts +41 -0
  127. package/src/plugin/gateway-runtime.ts +17 -4
  128. package/src/plugin/inbound-acceptance.ts +107 -0
  129. package/src/plugin/inbound-handlers.ts +248 -0
  130. package/src/plugin/inbound-surface-handlers-group.ts +152 -0
  131. package/src/plugin/media-dedupe-runtime.ts +90 -0
  132. package/src/plugin/media-orchestrators-runtime-group.ts +316 -0
  133. package/src/plugin/message-ack-runtime.ts +284 -0
  134. package/src/plugin/message-send.ts +16 -6
  135. package/src/plugin/messaging.ts +98 -36
  136. package/src/plugin/outbound.ts +50 -8
  137. package/src/plugin/outbox-ack-logs.ts +136 -0
  138. package/src/plugin/outbox-ack-outcome.ts +128 -0
  139. package/src/plugin/outbox-drain-ack.ts +145 -0
  140. package/src/plugin/outbox-drain-failure.ts +84 -0
  141. package/src/plugin/outbox-drain-loop.ts +554 -0
  142. package/src/plugin/outbox-drain-post-push.ts +159 -0
  143. package/src/plugin/outbox-drain-runtime.ts +141 -0
  144. package/src/plugin/outbox-drain-schedule.ts +116 -0
  145. package/src/plugin/outbox-file-push-flow.ts +69 -0
  146. package/src/plugin/outbox-push-route-runtime-group.ts +81 -0
  147. package/src/plugin/outbox-push.ts +267 -0
  148. package/src/plugin/outbox-route.ts +181 -0
  149. package/src/plugin/outbox-text-push-flow.ts +90 -0
  150. package/src/plugin/runtime-diagnostics-assembler.ts +183 -0
  151. package/src/plugin/runtime-diagnostics-helpers.ts +302 -0
  152. package/src/plugin/runtime-diagnostics-payload-builders.ts +171 -0
  153. package/src/plugin/runtime-diagnostics-snapshot.ts +31 -0
  154. package/src/plugin/setup.ts +33 -6
  155. package/src/plugin/state-store.ts +249 -0
  156. package/src/plugin/state-transient-runtime-group.ts +105 -0
  157. package/src/plugin/status-runtime.ts +251 -0
  158. package/src/plugin/status.ts +33 -7
  159. package/src/plugin/target-runtime.ts +141 -0
  160. package/src/plugin/target-status-runtime-group.ts +130 -0
  161. package/src/plugin/transient-state-runtime.ts +82 -0
  162. package/src/runtime/outbound-ack-timeout.ts +5 -3
  163. package/src/runtime/outbound-flags.ts +24 -8
  164. package/src/runtime/status-snapshots.ts +36 -7
  165. package/src/runtime/status-worker.ts +34 -4
@@ -0,0 +1,554 @@
1
+ import type { OutboxEntry } from '../core/types.ts';
2
+ import {
3
+ buildFlushDebugInfo,
4
+ buildOutboxDrainSkipDebugInfo,
5
+ buildOutboxDrainStuckDebugInfo,
6
+ } from '../messaging/outbound/diagnostics.ts';
7
+ import {
8
+ buildOutboxOnlineDebugInfo,
9
+ computeNextOutboxDelay,
10
+ findDueOutboxEntry,
11
+ listAccountOutboxEntries,
12
+ selectOutboxTargetAccounts,
13
+ updateMinOutboxDelay,
14
+ } from '../messaging/outbound/queue-selectors.ts';
15
+ import { OUTBOUND_SCHEDULE_SOURCE } from '../messaging/outbound/reasons.ts';
16
+
17
+ type FlushPushQueueArgs = {
18
+ accountId?: string;
19
+ trigger?: string;
20
+ reason?: string;
21
+ };
22
+
23
+ // Flush argument normalization ----------------------------------------------
24
+
25
+ // Flush entrypoint args are normalized once so the rest of the drain flow can
26
+ // work with canonical account/trigger/reason values.
27
+
28
+ function normalizeFlushPushQueueArgs(
29
+ args: FlushPushQueueArgs | undefined,
30
+ helpers: {
31
+ asString: (value: unknown, fallback?: string) => string;
32
+ normalizeAccountId: (accountId: string) => string;
33
+ },
34
+ ) {
35
+ const filterAcc = args?.accountId ? helpers.normalizeAccountId(args.accountId) : null;
36
+ const trigger = helpers.asString(args?.trigger || '').trim() || 'manual';
37
+ const reason = helpers.asString(args?.reason || '').trim() || undefined;
38
+ return { filterAcc, trigger, reason };
39
+ }
40
+
41
+ // The drain loop yields on either time or entry budget. These helpers stay
42
+ // local so the account-drain flow reads top-to-bottom without jumping files.
43
+
44
+ // Account-drain budget helpers ----------------------------------------------
45
+
46
+ function shouldYieldAccountDrainForTimeBudget(args: {
47
+ processedThisRun: number;
48
+ accountDrainStartedAt: number;
49
+ now: number;
50
+ pushDrainAccountTimeBudgetMs: number;
51
+ }) {
52
+ return (
53
+ args.processedThisRun > 0 &&
54
+ args.now - args.accountDrainStartedAt >= args.pushDrainAccountTimeBudgetMs
55
+ );
56
+ }
57
+
58
+ function shouldYieldAccountDrainForEntryBudget(args: {
59
+ processedThisRun: number;
60
+ pushDrainAccountBudget: number;
61
+ }) {
62
+ return args.processedThisRun >= args.pushDrainAccountBudget;
63
+ }
64
+
65
+ function scheduleAccountYieldForBudget(args: {
66
+ accountId: string;
67
+ source: string;
68
+ localNextDelay: number | null;
69
+ scheduleAccountYield: (args: {
70
+ accountId: string;
71
+ source: string;
72
+ localNextDelay: number | null;
73
+ updateMinOutboxDelay: typeof updateMinOutboxDelay;
74
+ }) => number | null;
75
+ }) {
76
+ return args.scheduleAccountYield({
77
+ accountId: args.accountId,
78
+ source: args.source,
79
+ localNextDelay: args.localNextDelay,
80
+ updateMinOutboxDelay,
81
+ });
82
+ }
83
+
84
+ type BncrOutboxDrainScheduleRuntime = {
85
+ scheduleAccountWait: (args: {
86
+ accountId: string;
87
+ messageId?: string;
88
+ source: string;
89
+ wait: number;
90
+ localNextDelay: number | null;
91
+ updateMinOutboxDelay: typeof updateMinOutboxDelay;
92
+ }) => number | null;
93
+ scheduleAccountYield: (args: {
94
+ accountId: string;
95
+ source: string;
96
+ localNextDelay: number | null;
97
+ updateMinOutboxDelay: typeof updateMinOutboxDelay;
98
+ }) => number | null;
99
+ mergeAccountNextDelay: (args: {
100
+ accountId: string;
101
+ localNextDelay: number;
102
+ globalNextDelay: number | null;
103
+ updateMinOutboxDelay: typeof updateMinOutboxDelay;
104
+ source: string;
105
+ }) => number | null;
106
+ scheduleFlushNextDrain: (args: { globalNextDelay: number; source: string }) => void;
107
+ };
108
+
109
+ type BncrOutboxDrainLoopRuntime = {
110
+ bridgeId: string;
111
+ now: () => number;
112
+ asString: (value: unknown, fallback?: string) => string;
113
+ normalizeAccountId: (accountId: string) => string;
114
+ stopped: () => boolean;
115
+ outbox: Map<string, OutboxEntry>;
116
+ connectionsValues: () => IterableIterator<{
117
+ accountId: string;
118
+ connId: string;
119
+ clientId?: string;
120
+ connectedAt: number;
121
+ lastSeenAt: number;
122
+ inboundOnly?: boolean;
123
+ outboundReady?: boolean;
124
+ preferredForOutbound?: boolean;
125
+ outboundReadyUntil?: number;
126
+ preferredForOutboundUntil?: number;
127
+ lastAckOkAt?: number;
128
+ lastPushTimeoutAt?: number;
129
+ pushFailureScore?: number;
130
+ }>;
131
+ gatewayContextAvailable: () => boolean;
132
+ messageAckWaiterCount: () => number;
133
+ fileAckWaiterCount: () => number;
134
+ activeConnectionCount: (accountId: string) => number;
135
+ getAccountPendingOutboxEntries: (accountId: string) => OutboxEntry[];
136
+ pushDrainRunningAccounts: Set<string>;
137
+ pushDrainRunningSinceByAccount: Map<string, number>;
138
+ pushDrainStuckWarnedAtByAccount: Map<string, number>;
139
+ isOnline: (accountId: string) => boolean;
140
+ hasRecentInboundReachability: (accountId: string) => boolean;
141
+ sleepMs: (ms: number) => Promise<void>;
142
+ schedulePushDrain: (delayMs: number) => void;
143
+ outboxDrainSchedule: BncrOutboxDrainScheduleRuntime;
144
+ tryPushEntry: (entry: OutboxEntry) => Promise<boolean>;
145
+ handleFileTransferPushFailure: (args: { entry: OutboxEntry; error: unknown }) => void;
146
+ handleTextPushFailure: (args: { entry: OutboxEntry; error: unknown }) => void;
147
+ isPlainObject: (value: unknown) => value is Record<string, unknown>;
148
+ logInfo: (scope: string, message: string, options?: { debugOnly?: boolean }) => void;
149
+ logWarn: (scope: string, message: string, options?: { debugOnly?: boolean }) => void;
150
+ pushDrainStuckWarnMs: number;
151
+ pushDrainIntervalMs: number;
152
+ pushDrainAccountTimeBudgetMs: number;
153
+ pushDrainAccountBudget: number;
154
+ };
155
+
156
+ export function createBncrOutboxDrainLoop(
157
+ runtime: BncrOutboxDrainLoopRuntime,
158
+ handlers: {
159
+ handlePushedDrainEntry: (args: {
160
+ accountId: string;
161
+ entry: OutboxEntry;
162
+ onlineNow: boolean;
163
+ recentInboundReachable: boolean;
164
+ localNextDelay: number | null;
165
+ updateMinOutboxDelay: typeof updateMinOutboxDelay;
166
+ }) => Promise<{ action: 'continue' | 'break'; localNextDelay: number | null }>;
167
+ handleFailedDrainEntry: (args: {
168
+ accountId: string;
169
+ entry: OutboxEntry;
170
+ localNextDelay: number | null;
171
+ attemptedAt: number;
172
+ updateMinOutboxDelay: typeof updateMinOutboxDelay;
173
+ }) => { action: 'continue' | 'break'; localNextDelay: number | null };
174
+ },
175
+ ) {
176
+ // Flow order in this module is intentional:
177
+ // 1) account selection helpers
178
+ // 2) drain observability and next-entry lookup
179
+ // 3) single-entry push attempt
180
+ // 4) per-account cooperative loop
181
+ // 5) per-account cycle orchestration
182
+ // 6) global flush entrypoint
183
+
184
+ // Account-scoped queue selectors -----------------------------------------
185
+
186
+ const buildAccountEntries = (accountId: string) => {
187
+ return listAccountOutboxEntries({
188
+ accountId,
189
+ outboxEntries: runtime.outbox.values(),
190
+ normalizeAccountId: runtime.normalizeAccountId,
191
+ });
192
+ };
193
+
194
+ // Re-entrant drains are expected under heavy ACK / retry churn. This warning
195
+ // path is the observability guardrail for drains that stop making progress.
196
+
197
+ // Drain observability / next-entry selection ------------------------------
198
+ function maybeLogOutboxDrainStuck(args: { accountId: string; trigger: string; reason: string }) {
199
+ const acc = runtime.normalizeAccountId(args.accountId);
200
+ const startedAt = runtime.pushDrainRunningSinceByAccount.get(acc) || 0;
201
+ if (!startedAt) return;
202
+
203
+ const t = runtime.now();
204
+ const runningMs = Math.max(0, t - startedAt);
205
+ if (runningMs < runtime.pushDrainStuckWarnMs) return;
206
+
207
+ const lastWarnedAt = runtime.pushDrainStuckWarnedAtByAccount.get(acc) || 0;
208
+ if (lastWarnedAt && t - lastWarnedAt < runtime.pushDrainStuckWarnMs) return;
209
+
210
+ const pendingEntries = runtime.getAccountPendingOutboxEntries(acc);
211
+ const pending = pendingEntries.length;
212
+ if (!pending) return;
213
+
214
+ runtime.pushDrainStuckWarnedAtByAccount.set(acc, t);
215
+ runtime.logWarn(
216
+ 'outbox drain stuck',
217
+ `accountId=${acc}|pending=${pending}|runningMs=${runningMs}|waiters=${runtime.messageAckWaiterCount()}/${runtime.fileAckWaiterCount()}`,
218
+ );
219
+ runtime.logInfo(
220
+ 'outbox',
221
+ `drain-stuck ${JSON.stringify(
222
+ buildOutboxDrainStuckDebugInfo({
223
+ bridgeId: runtime.bridgeId,
224
+ accountId: acc,
225
+ reason: args.reason,
226
+ trigger: args.trigger,
227
+ outboxSize: runtime.outbox.size,
228
+ pending,
229
+ runningMs,
230
+ runningSince: startedAt,
231
+ hasGatewayContext: runtime.gatewayContextAvailable(),
232
+ activeConnectionCount: runtime.activeConnectionCount(acc),
233
+ messageAckWaiters: runtime.messageAckWaiterCount(),
234
+ fileAckWaiters: runtime.fileAckWaiterCount(),
235
+ pendingEntries,
236
+ connections: runtime.connectionsValues(),
237
+ }),
238
+ )}`,
239
+ { debugOnly: true },
240
+ );
241
+ }
242
+
243
+ function resolveNextDrainEntry(args: {
244
+ accountId: string;
245
+ attemptedAt: number;
246
+ localNextDelay: number | null;
247
+ }): {
248
+ entry: OutboxEntry | null;
249
+ localNextDelay: number | null;
250
+ shouldBreak: boolean;
251
+ } {
252
+ const { accountId, attemptedAt } = args;
253
+ let { localNextDelay } = args;
254
+ const entries = buildAccountEntries(accountId);
255
+
256
+ if (!entries.length) {
257
+ return { entry: null, localNextDelay, shouldBreak: true };
258
+ }
259
+
260
+ const entry = findDueOutboxEntry(entries, attemptedAt);
261
+ if (entry) {
262
+ return { entry, localNextDelay, shouldBreak: false };
263
+ }
264
+
265
+ const wait = computeNextOutboxDelay(entries, attemptedAt);
266
+ if (wait != null) {
267
+ localNextDelay = runtime.outboxDrainSchedule.scheduleAccountWait({
268
+ accountId,
269
+ source: OUTBOUND_SCHEDULE_SOURCE.ACCOUNT_NO_DUE_ENTRY,
270
+ wait,
271
+ localNextDelay,
272
+ updateMinOutboxDelay,
273
+ });
274
+ }
275
+
276
+ return { entry: null, localNextDelay, shouldBreak: true };
277
+ }
278
+
279
+ // One push attempt decides whether the loop continues immediately, waits for
280
+ // retry scheduling, or exits back to the caller with a next-delay hint.
281
+
282
+ // Single-entry push attempt ------------------------------------------------
283
+
284
+ async function attemptDrainEntryPush(args: {
285
+ accountId: string;
286
+ entry: OutboxEntry;
287
+ localNextDelay: number | null;
288
+ attemptedAt: number;
289
+ }): Promise<{ action: 'continue' | 'break'; localNextDelay: number | null }> {
290
+ const { accountId, entry, attemptedAt } = args;
291
+ const { localNextDelay } = args;
292
+ const onlineNow = runtime.isOnline(accountId);
293
+ const recentInboundReachable = runtime.hasRecentInboundReachability(accountId);
294
+ let pushed = false;
295
+ try {
296
+ pushed = await runtime.tryPushEntry(entry);
297
+ } catch (error) {
298
+ const meta = runtime.isPlainObject(entry.payload?._meta) ? entry.payload._meta : null;
299
+ if (meta?.kind === 'file-transfer') {
300
+ runtime.handleFileTransferPushFailure({ entry, error });
301
+ } else {
302
+ runtime.handleTextPushFailure({ entry, error });
303
+ }
304
+ pushed = false;
305
+ }
306
+
307
+ if (pushed) {
308
+ return await handlers.handlePushedDrainEntry({
309
+ accountId,
310
+ entry,
311
+ onlineNow,
312
+ recentInboundReachable,
313
+ localNextDelay,
314
+ updateMinOutboxDelay,
315
+ });
316
+ }
317
+
318
+ if (!runtime.outbox.has(entry.messageId)) {
319
+ await runtime.sleepMs(runtime.pushDrainIntervalMs);
320
+ return { action: 'continue', localNextDelay };
321
+ }
322
+
323
+ return handlers.handleFailedDrainEntry({
324
+ accountId,
325
+ entry,
326
+ localNextDelay,
327
+ attemptedAt,
328
+ updateMinOutboxDelay,
329
+ });
330
+ }
331
+
332
+ // The inner account loop yields cooperatively before it monopolizes the
333
+ // bridge. That keeps multi-account drains and ACK callbacks responsive.
334
+
335
+ // Per-account drain loop --------------------------------------------------
336
+
337
+ function shouldYieldAccountDrain(args: {
338
+ accountId: string;
339
+ processedThisRun: number;
340
+ accountDrainStartedAt: number;
341
+ localNextDelay: number | null;
342
+ }): { shouldBreak: boolean; localNextDelay: number | null } {
343
+ const { accountId, processedThisRun, accountDrainStartedAt } = args;
344
+ let { localNextDelay } = args;
345
+
346
+ if (
347
+ shouldYieldAccountDrainForTimeBudget({
348
+ processedThisRun,
349
+ accountDrainStartedAt,
350
+ now: runtime.now(),
351
+ pushDrainAccountTimeBudgetMs: runtime.pushDrainAccountTimeBudgetMs,
352
+ })
353
+ ) {
354
+ localNextDelay = scheduleAccountYieldForBudget({
355
+ accountId,
356
+ source: OUTBOUND_SCHEDULE_SOURCE.ACCOUNT_TIME_BUDGET_YIELD,
357
+ localNextDelay,
358
+ scheduleAccountYield: runtime.outboxDrainSchedule.scheduleAccountYield,
359
+ });
360
+ return { shouldBreak: true, localNextDelay };
361
+ }
362
+
363
+ if (
364
+ shouldYieldAccountDrainForEntryBudget({
365
+ processedThisRun,
366
+ pushDrainAccountBudget: runtime.pushDrainAccountBudget,
367
+ })
368
+ ) {
369
+ localNextDelay = scheduleAccountYieldForBudget({
370
+ accountId,
371
+ source: OUTBOUND_SCHEDULE_SOURCE.ACCOUNT_BUDGET_YIELD,
372
+ localNextDelay,
373
+ scheduleAccountYield: runtime.outboxDrainSchedule.scheduleAccountYield,
374
+ });
375
+ return { shouldBreak: true, localNextDelay };
376
+ }
377
+
378
+ return { shouldBreak: false, localNextDelay };
379
+ }
380
+
381
+ async function drainAccountOutbox(args: { accountId: string }): Promise<number | null> {
382
+ const { accountId } = args;
383
+ let localNextDelay: number | null = null;
384
+ let processedThisRun = 0;
385
+ const accountDrainStartedAt = runtime.now();
386
+
387
+ while (true) {
388
+ if (runtime.stopped()) break;
389
+ const yieldCheck = shouldYieldAccountDrain({
390
+ accountId,
391
+ processedThisRun,
392
+ accountDrainStartedAt,
393
+ localNextDelay,
394
+ });
395
+ localNextDelay = yieldCheck.localNextDelay;
396
+ if (yieldCheck.shouldBreak) break;
397
+ const t = runtime.now();
398
+ const nextEntry = resolveNextDrainEntry({
399
+ accountId,
400
+ attemptedAt: t,
401
+ localNextDelay,
402
+ });
403
+ localNextDelay = nextEntry.localNextDelay;
404
+ if (nextEntry.shouldBreak || !nextEntry.entry) break;
405
+
406
+ processedThisRun += 1;
407
+ const result = await attemptDrainEntryPush({
408
+ accountId,
409
+ entry: nextEntry.entry,
410
+ localNextDelay,
411
+ attemptedAt: t,
412
+ });
413
+ localNextDelay = result.localNextDelay;
414
+ if (result.action === 'continue') continue;
415
+ break;
416
+ }
417
+
418
+ return localNextDelay;
419
+ }
420
+
421
+ // A drain cycle owns one account at a time: emit state, prevent re-entry,
422
+ // run the account loop, then merge any next-delay back into the global flush.
423
+
424
+ // Per-account cycle orchestration ----------------------------------------
425
+
426
+ async function runAccountDrainCycle(args: {
427
+ accountId: string;
428
+ trigger: string;
429
+ reason?: string;
430
+ globalNextDelay: number | null;
431
+ }): Promise<number | null> {
432
+ const { accountId, trigger, reason } = args;
433
+ let { globalNextDelay } = args;
434
+
435
+ if (runtime.pushDrainRunningAccounts.has(accountId)) {
436
+ runtime.logInfo(
437
+ 'outbox',
438
+ `drain-skip ${JSON.stringify(
439
+ buildOutboxDrainSkipDebugInfo({
440
+ bridgeId: runtime.bridgeId,
441
+ accountId,
442
+ reason: 'already-running',
443
+ outboxSize: runtime.outbox.size,
444
+ trigger,
445
+ }),
446
+ )}`,
447
+ { debugOnly: true },
448
+ );
449
+ maybeLogOutboxDrainStuck({
450
+ accountId,
451
+ trigger,
452
+ reason: reason || 'already-running',
453
+ });
454
+ return globalNextDelay;
455
+ }
456
+
457
+ const online = runtime.isOnline(accountId);
458
+ const recentInboundReachable = runtime.hasRecentInboundReachability(accountId);
459
+ runtime.logInfo(
460
+ 'outbox',
461
+ `online ${JSON.stringify(
462
+ buildOutboxOnlineDebugInfo({
463
+ bridgeId: runtime.bridgeId,
464
+ accountId,
465
+ online,
466
+ recentInboundReachable,
467
+ connections: runtime.connectionsValues(),
468
+ }),
469
+ )}`,
470
+ { debugOnly: true },
471
+ );
472
+
473
+ runtime.pushDrainRunningAccounts.add(accountId);
474
+ runtime.pushDrainRunningSinceByAccount.set(accountId, runtime.now());
475
+ runtime.pushDrainStuckWarnedAtByAccount.delete(accountId);
476
+ try {
477
+ const localNextDelay = await drainAccountOutbox({ accountId });
478
+ if (localNextDelay != null) {
479
+ globalNextDelay = runtime.outboxDrainSchedule.mergeAccountNextDelay({
480
+ accountId,
481
+ localNextDelay,
482
+ globalNextDelay,
483
+ updateMinOutboxDelay,
484
+ source: OUTBOUND_SCHEDULE_SOURCE.ACCOUNT_NEXT_DELAY_MERGE,
485
+ });
486
+ }
487
+ } finally {
488
+ runtime.pushDrainRunningAccounts.delete(accountId);
489
+ runtime.pushDrainRunningSinceByAccount.delete(accountId);
490
+ runtime.pushDrainStuckWarnedAtByAccount.delete(accountId);
491
+ }
492
+
493
+ return globalNextDelay;
494
+ }
495
+
496
+ // Global flush entrypoint -------------------------------------------------
497
+
498
+ async function flushPushQueue(args?: {
499
+ accountId?: string;
500
+ trigger?: string;
501
+ reason?: string;
502
+ }): Promise<void> {
503
+ if (runtime.stopped()) return;
504
+ const { filterAcc, trigger, reason } = normalizeFlushPushQueueArgs(args, {
505
+ asString: runtime.asString,
506
+ normalizeAccountId: runtime.normalizeAccountId,
507
+ });
508
+ const targetAccounts = selectOutboxTargetAccounts({
509
+ accountId: filterAcc,
510
+ outboxEntries: runtime.outbox.values(),
511
+ normalizeAccountId: runtime.normalizeAccountId,
512
+ });
513
+ runtime.logInfo(
514
+ 'outbox',
515
+ `flush ${JSON.stringify(
516
+ buildFlushDebugInfo({
517
+ bridgeId: runtime.bridgeId,
518
+ accountId: filterAcc,
519
+ targetAccounts,
520
+ outboxSize: runtime.outbox.size,
521
+ trigger,
522
+ reason,
523
+ }),
524
+ )}`,
525
+ { debugOnly: true },
526
+ );
527
+
528
+ let globalNextDelay: number | null = null;
529
+
530
+ for (const acc of targetAccounts) {
531
+ if (!acc) continue;
532
+ globalNextDelay = await runAccountDrainCycle({
533
+ accountId: acc,
534
+ trigger,
535
+ reason,
536
+ globalNextDelay,
537
+ });
538
+ }
539
+
540
+ if (globalNextDelay != null) {
541
+ runtime.outboxDrainSchedule.scheduleFlushNextDrain({
542
+ globalNextDelay,
543
+ source: OUTBOUND_SCHEDULE_SOURCE.FLUSH_NEXT_DRAIN,
544
+ });
545
+ runtime.schedulePushDrain(globalNextDelay);
546
+ }
547
+ }
548
+
549
+ return {
550
+ maybeLogOutboxDrainStuck,
551
+ runAccountDrainCycle,
552
+ flushPushQueue,
553
+ };
554
+ }
@@ -0,0 +1,159 @@
1
+ import type { OutboxEntry } from '../core/types.ts';
2
+ import { OUTBOUND_DEGRADE_REASON } from '../messaging/outbound/reasons.ts';
3
+ import {
4
+ computeRetryRerouteDecision,
5
+ type RetryRerouteDecision,
6
+ } from '../messaging/outbound/retry-policy.ts';
7
+
8
+ type UpdateMinOutboxDelay = (current: number | null, candidate: number | null) => number | null;
9
+
10
+ type BncrOutboxDrainAckRuntime = {
11
+ logAckWaitStart: (args: {
12
+ entry: OutboxEntry;
13
+ requireAck: boolean;
14
+ ackTimeoutMs: number | null;
15
+ onlineNow: boolean;
16
+ recentInboundReachable: boolean;
17
+ }) => void;
18
+ handleAckTimeoutReroute: (args: {
19
+ accountId: string;
20
+ entry: OutboxEntry;
21
+ requireAck: boolean;
22
+ currentConnId: string;
23
+ availableConnIds: string[];
24
+ decision: RetryRerouteDecision;
25
+ localNextDelay: number | null;
26
+ ackTimeoutMs: number | null;
27
+ updateMinOutboxDelay: UpdateMinOutboxDelay;
28
+ }) => { kind: 'dead-letter' } | { kind: 'retry'; localNextDelay: number | null };
29
+ };
30
+
31
+ type BncrOutboxDrainPostPushRuntime = {
32
+ now: () => number;
33
+ asString: (value: unknown, fallback?: string) => string;
34
+ backoffMs: (retryCount: number) => number;
35
+ outbox: Map<string, OutboxEntry>;
36
+ isOutboundAckRequired: (accountId?: string) => boolean;
37
+ resolveMessageAckTimeoutMs: (accountId: string) => number;
38
+ waitForMessageAck: (messageId: string, waitMs: number) => Promise<'acked' | 'timeout'>;
39
+ logOutboxAckWait: (args: {
40
+ entry: OutboxEntry;
41
+ requireAck: boolean;
42
+ ackResult: 'acked' | 'timeout';
43
+ onlineNow: boolean;
44
+ recentInboundReachable: boolean;
45
+ ackTimeoutMs: number | null;
46
+ }) => void;
47
+ degradeOutboundCapability: (args: {
48
+ accountId: string;
49
+ connId?: string;
50
+ clientId?: string;
51
+ reason: string;
52
+ }) => void;
53
+ resolvePushConnIds: (accountId: string) => Iterable<string>;
54
+ sleepMs: (ms: number) => Promise<void>;
55
+ outboxDrainAck: BncrOutboxDrainAckRuntime;
56
+ pushDrainIntervalMs: number;
57
+ pushAckTimeoutMs: number;
58
+ maxRetry: number;
59
+ };
60
+
61
+ export function createBncrOutboxDrainPostPush(runtime: BncrOutboxDrainPostPushRuntime) {
62
+ return async function handlePushedDrainEntry(args: {
63
+ accountId: string;
64
+ entry: OutboxEntry;
65
+ onlineNow: boolean;
66
+ recentInboundReachable: boolean;
67
+ localNextDelay: number | null;
68
+ updateMinOutboxDelay: UpdateMinOutboxDelay;
69
+ }): Promise<{ action: 'continue' | 'break'; localNextDelay: number | null }> {
70
+ const { accountId, entry, onlineNow, recentInboundReachable, updateMinOutboxDelay } = args;
71
+ let { localNextDelay } = args;
72
+ const requireAck = runtime.isOutboundAckRequired(accountId);
73
+ const ackTimeoutMs = requireAck ? runtime.resolveMessageAckTimeoutMs(accountId) : null;
74
+ let ackResult: 'acked' | 'timeout' = requireAck ? 'timeout' : 'acked';
75
+ if (onlineNow && requireAck) {
76
+ runtime.outboxDrainAck.logAckWaitStart({
77
+ entry,
78
+ requireAck,
79
+ ackTimeoutMs,
80
+ onlineNow,
81
+ recentInboundReachable,
82
+ });
83
+ ackResult = await runtime.waitForMessageAck(
84
+ entry.messageId,
85
+ ackTimeoutMs || runtime.pushAckTimeoutMs,
86
+ );
87
+ }
88
+
89
+ runtime.logOutboxAckWait({
90
+ entry,
91
+ requireAck,
92
+ ackResult,
93
+ onlineNow,
94
+ recentInboundReachable,
95
+ ackTimeoutMs,
96
+ });
97
+
98
+ if (!runtime.outbox.has(entry.messageId)) {
99
+ await runtime.sleepMs(runtime.pushDrainIntervalMs);
100
+ return { action: 'continue', localNextDelay };
101
+ }
102
+
103
+ if (onlineNow && (!requireAck || ackResult !== 'timeout')) {
104
+ await runtime.sleepMs(runtime.pushDrainIntervalMs);
105
+ return { action: 'continue', localNextDelay };
106
+ }
107
+
108
+ if (entry.lastPushConnId || entry.lastPushClientId) {
109
+ runtime.degradeOutboundCapability({
110
+ accountId,
111
+ connId: entry.lastPushConnId || undefined,
112
+ clientId: entry.lastPushClientId || undefined,
113
+ reason: requireAck
114
+ ? OUTBOUND_DEGRADE_REASON.ACK_TIMEOUT
115
+ : OUTBOUND_DEGRADE_REASON.PUSH_UNCONFIRMED,
116
+ });
117
+ }
118
+
119
+ const attemptedConnIds = Array.isArray(entry.routeAttemptConnIds)
120
+ ? entry.routeAttemptConnIds.filter((v): v is string => typeof v === 'string' && !!v)
121
+ : [];
122
+ const currentConnId = runtime.asString(entry.lastPushConnId || '').trim();
123
+ const availableConnIds = Array.from(runtime.resolvePushConnIds(accountId));
124
+ const decision = computeRetryRerouteDecision(
125
+ {
126
+ nowMs: runtime.now(),
127
+ maxRetry: runtime.maxRetry,
128
+ requireAck,
129
+ currentRetryCount: entry.retryCount,
130
+ currentRouteAttemptRound: Number(entry.routeAttemptRound || 0),
131
+ currentFastReroutePending: entry.fastReroutePending === true,
132
+ lastError: entry.lastError,
133
+ currentConnId: currentConnId || undefined,
134
+ attemptedConnIds,
135
+ availableConnIds,
136
+ },
137
+ { backoffMs: runtime.backoffMs },
138
+ );
139
+
140
+ const rerouteResult = runtime.outboxDrainAck.handleAckTimeoutReroute({
141
+ accountId,
142
+ entry,
143
+ requireAck,
144
+ currentConnId,
145
+ availableConnIds,
146
+ decision,
147
+ localNextDelay,
148
+ ackTimeoutMs,
149
+ updateMinOutboxDelay,
150
+ });
151
+ if (rerouteResult.kind === 'dead-letter') {
152
+ return { action: 'continue', localNextDelay };
153
+ }
154
+
155
+ localNextDelay = rerouteResult.localNextDelay;
156
+ await runtime.sleepMs(runtime.pushDrainIntervalMs);
157
+ return { action: 'break', localNextDelay };
158
+ };
159
+ }