@xmoxmo/bncr 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/channel.ts CHANGED
@@ -9,13 +9,12 @@ import type {
9
9
  } from 'openclaw/plugin-sdk/core';
10
10
  import {
11
11
  applyAccountNameToChannelSection,
12
+ jsonResult,
12
13
  setAccountEnabledInConfigSection,
13
14
  } from 'openclaw/plugin-sdk/core';
14
15
  import { readJsonFileWithFallback, writeJsonFileAtomically } from 'openclaw/plugin-sdk/json-store';
15
- import type { ChannelMessageActionAdapter, ChatType } from 'openclaw/plugin-sdk/mattermost';
16
16
  import { readStringParam } from 'openclaw/plugin-sdk/param-readers';
17
17
  import { createDefaultChannelRuntimeState } from 'openclaw/plugin-sdk/status-helpers';
18
- import { jsonResult } from 'openclaw/plugin-sdk/telegram-core';
19
18
  import { extractToolSend } from 'openclaw/plugin-sdk/tool-send';
20
19
  import {
21
20
  BNCR_DEFAULT_ACCOUNT_ID,
@@ -74,8 +73,13 @@ import {
74
73
  resolveBncrOutboundTarget,
75
74
  } from './messaging/outbound/target-resolver.ts';
76
75
  const BRIDGE_VERSION = 2;
77
- const BNCR_PUSH_EVENT = 'bncr.push';
76
+ const BNCR_PUSH_EVENT = 'plugin.bncr.push';
77
+ const BNCR_FILE_INIT_EVENT = 'plugin.bncr.file.init';
78
+ const BNCR_FILE_CHUNK_EVENT = 'plugin.bncr.file.chunk';
79
+ const BNCR_FILE_COMPLETE_EVENT = 'plugin.bncr.file.complete';
80
+ const BNCR_FILE_ABORT_EVENT = 'plugin.bncr.file.abort';
78
81
  const CONNECT_TTL_MS = 120_000;
82
+ const RECENT_INBOUND_SEND_WINDOW_MS = 60_000;
79
83
  const MAX_RETRY = 10;
80
84
  const PUSH_DRAIN_INTERVAL_MS = 500;
81
85
  const PUSH_ACK_TIMEOUT_MS = 30_000;
@@ -104,6 +108,8 @@ type FileSendTransferState = {
104
108
  status: 'init' | 'transferring' | 'completed' | 'aborted';
105
109
  ackedChunks: Set<number>;
106
110
  failedChunks: Map<number, string>;
111
+ ownerConnId?: string;
112
+ ownerClientId?: string;
107
113
  completedPath?: string;
108
114
  error?: string;
109
115
  };
@@ -123,10 +129,26 @@ type FileRecvTransferState = {
123
129
  status: 'init' | 'transferring' | 'completed' | 'aborted';
124
130
  bufferByChunk: Map<number, Buffer>;
125
131
  receivedChunks: Set<number>;
132
+ ownerConnId?: string;
133
+ ownerClientId?: string;
126
134
  completedPath?: string;
127
135
  error?: string;
128
136
  };
129
137
 
138
+ type ChatType = 'direct' | 'group' | (string & {});
139
+
140
+ type ChannelMessageActionAdapter = {
141
+ describeMessageTool: (ctx: { cfg: any }) => { actions: string[]; capabilities: unknown[] } | null;
142
+ supportsAction: (ctx: { action: string }) => boolean;
143
+ extractToolSend: (ctx: { args: unknown }) => unknown;
144
+ handleAction: (ctx: {
145
+ action: string;
146
+ params: unknown;
147
+ accountId: string;
148
+ mediaLocalRoots?: string[];
149
+ }) => Promise<unknown>;
150
+ };
151
+
130
152
  type PersistedState = {
131
153
  outbox: OutboxEntry[];
132
154
  deadLetter: OutboxEntry[];
@@ -341,7 +363,13 @@ class BncrBridgeRuntime {
341
363
  private saveTimer: NodeJS.Timeout | null = null;
342
364
  private pushTimer: NodeJS.Timeout | null = null;
343
365
  private pushDrainRunningAccounts = new Set<string>();
344
- private waiters = new Map<string, Array<() => void>>();
366
+ private messageAckWaiters = new Map<
367
+ string,
368
+ {
369
+ resolve: (result: 'acked' | 'timeout') => void;
370
+ timer: NodeJS.Timeout;
371
+ }
372
+ >();
345
373
  private gatewayContext: GatewayRequestHandlerOptions['context'] | null = null;
346
374
 
347
375
  // 文件互传状态(V1:尽力而为,重连不续传)
@@ -647,6 +675,45 @@ class BncrBridgeRuntime {
647
675
  return { stale: true, reason: 'mismatch' as const };
648
676
  }
649
677
 
678
+ private shouldIgnoreStaleEvent(params: {
679
+ kind:
680
+ | 'inbound'
681
+ | 'activity'
682
+ | 'ack'
683
+ | 'file.init'
684
+ | 'file.chunk'
685
+ | 'file.complete'
686
+ | 'file.abort';
687
+ payload: { leaseId?: string; connectionEpoch?: number };
688
+ accountId: string;
689
+ connId: string;
690
+ clientId?: string;
691
+ }) {
692
+ const observed = this.observeLease(params.kind, params.payload);
693
+ if (!observed.stale) return false;
694
+ this.logWarn(
695
+ 'stale',
696
+ `ignore kind=${params.kind} accountId=${params.accountId} connId=${params.connId} clientId=${params.clientId || '-'} reason=${observed.reason}`,
697
+ { debugOnly: true },
698
+ );
699
+ return true;
700
+ }
701
+
702
+ private matchesTransferOwner(params: {
703
+ ownerConnId?: string;
704
+ ownerClientId?: string;
705
+ connId: string;
706
+ clientId?: string;
707
+ }) {
708
+ const sameConn = !!params.ownerConnId && params.ownerConnId === params.connId;
709
+ const sameClient =
710
+ !params.ownerConnId &&
711
+ !!params.ownerClientId &&
712
+ !!params.clientId &&
713
+ params.ownerClientId === params.clientId;
714
+ return sameConn || sameClient;
715
+ }
716
+
650
717
  private buildExtendedDiagnostics(accountId: string) {
651
718
  const diagnostics = this.buildIntegratedDiagnostics(accountId) as Record<string, any>;
652
719
  return {
@@ -709,7 +776,7 @@ class BncrBridgeRuntime {
709
776
  this.statePath = path.join(ctx.stateDir, 'bncr-bridge-state.json');
710
777
  await this.loadState();
711
778
  try {
712
- const cfg = await this.api.runtime.config.loadConfig();
779
+ const cfg = this.api.runtime.config.current();
713
780
  this.initializeCanonicalAgentId(cfg);
714
781
  } catch {
715
782
  // ignore startup canonical agent initialization errors
@@ -737,6 +804,25 @@ class BncrBridgeRuntime {
737
804
  this.logInfo('debug', 'service stopped', { debugOnly: true });
738
805
  };
739
806
 
807
+ shutdown() {
808
+ if (this.saveTimer) {
809
+ clearTimeout(this.saveTimer);
810
+ this.saveTimer = null;
811
+ }
812
+ if (this.pushTimer) {
813
+ clearTimeout(this.pushTimer);
814
+ this.pushTimer = null;
815
+ }
816
+ for (const waiter of this.messageAckWaiters.values()) {
817
+ clearTimeout(waiter.timer);
818
+ }
819
+ this.messageAckWaiters.clear();
820
+ for (const waiter of this.fileAckWaiters.values()) {
821
+ clearTimeout(waiter.timer);
822
+ }
823
+ this.fileAckWaiters.clear();
824
+ }
825
+
740
826
  private scheduleSave() {
741
827
  if (this.saveTimer) return;
742
828
  this.saveTimer = setTimeout(() => {
@@ -756,7 +842,7 @@ class BncrBridgeRuntime {
756
842
 
757
843
  private async refreshDebugFlagFromConfig(options?: { forceLog?: boolean }) {
758
844
  try {
759
- const cfg = await this.api.runtime.config.loadConfig();
845
+ const cfg = this.api.runtime.config.current();
760
846
  const raw = (cfg as any)?.channels?.[CHANNEL_ID]?.debug?.verbose;
761
847
  const next = typeof raw === 'boolean' ? raw : false;
762
848
  const changed = next !== BNCR_DEBUG_VERBOSE;
@@ -1157,18 +1243,32 @@ class BncrBridgeRuntime {
1157
1243
  await writeJsonFileAtomically(this.statePath, data);
1158
1244
  }
1159
1245
 
1160
- private wakeAccountWaiters(accountId: string) {
1161
- const key = normalizeAccountId(accountId);
1162
- const waits = this.waiters.get(key);
1163
- if (!waits?.length) return;
1164
- this.waiters.delete(key);
1165
- for (const resolve of waits) resolve();
1246
+ private resolveMessageAck(messageId: string, result: 'acked' | 'timeout' = 'acked') {
1247
+ const key = asString(messageId).trim();
1248
+ if (!key) return false;
1249
+ const waiter = this.messageAckWaiters.get(key);
1250
+ if (!waiter) return false;
1251
+ this.messageAckWaiters.delete(key);
1252
+ clearTimeout(waiter.timer);
1253
+ waiter.resolve(result);
1254
+ return true;
1166
1255
  }
1167
1256
 
1168
1257
  private rememberGatewayContext(context: GatewayRequestHandlerOptions['context']) {
1169
1258
  if (context) this.gatewayContext = context;
1170
1259
  }
1171
1260
 
1261
+ private resolveOutboxPushOwner(accountId: string): BncrConnection | null {
1262
+ const acc = normalizeAccountId(accountId);
1263
+ const t = now();
1264
+ const primaryKey = this.activeConnectionByAccount.get(acc);
1265
+ if (!primaryKey) return null;
1266
+ const primary = this.connections.get(primaryKey);
1267
+ if (!primary?.connId) return null;
1268
+ if (t - primary.lastSeenAt > CONNECT_TTL_MS) return null;
1269
+ return primary;
1270
+ }
1271
+
1172
1272
  private resolvePushConnIds(accountId: string): Set<string> {
1173
1273
  const acc = normalizeAccountId(accountId);
1174
1274
  const t = now();
@@ -1194,6 +1294,31 @@ class BncrBridgeRuntime {
1194
1294
  return connIds;
1195
1295
  }
1196
1296
 
1297
+ private hasRecentInboundReachability(accountId: string): boolean {
1298
+ const acc = normalizeAccountId(accountId);
1299
+ const t = now();
1300
+ const lastInboundAt = this.lastInboundByAccount.get(acc) || 0;
1301
+ const lastActivityAt = this.lastActivityByAccount.get(acc) || 0;
1302
+ const lastReachableAt = Math.max(lastInboundAt, lastActivityAt);
1303
+ return lastReachableAt > 0 && t - lastReachableAt <= RECENT_INBOUND_SEND_WINDOW_MS;
1304
+ }
1305
+
1306
+ private resolveRecentInboundConnIds(accountId: string): Set<string> {
1307
+ const acc = normalizeAccountId(accountId);
1308
+ const t = now();
1309
+ const connIds = new Set<string>();
1310
+ if (!this.hasRecentInboundReachability(acc)) return connIds;
1311
+
1312
+ for (const c of this.connections.values()) {
1313
+ if (c.accountId !== acc) continue;
1314
+ if (!c.connId) continue;
1315
+ if (t - c.lastSeenAt > CONNECT_TTL_MS * 2) continue;
1316
+ connIds.add(c.connId);
1317
+ }
1318
+
1319
+ return connIds;
1320
+ }
1321
+
1197
1322
  private tryPushEntry(entry: OutboxEntry): boolean {
1198
1323
  const ctx = this.gatewayContext;
1199
1324
  if (!ctx) {
@@ -1209,7 +1334,14 @@ class BncrBridgeRuntime {
1209
1334
  return false;
1210
1335
  }
1211
1336
 
1212
- const connIds = this.resolvePushConnIds(entry.accountId);
1337
+ const owner = this.resolveOutboxPushOwner(entry.accountId);
1338
+ let connIds = owner?.connId
1339
+ ? new Set([owner.connId])
1340
+ : this.resolvePushConnIds(entry.accountId);
1341
+ const recentInboundReachable = this.hasRecentInboundReachability(entry.accountId);
1342
+ if (!connIds.size && recentInboundReachable) {
1343
+ connIds = this.resolveRecentInboundConnIds(entry.accountId);
1344
+ }
1213
1345
  if (!connIds.size) {
1214
1346
  this.logInfo(
1215
1347
  'outbox',
@@ -1217,6 +1349,7 @@ class BncrBridgeRuntime {
1217
1349
  messageId: entry.messageId,
1218
1350
  accountId: entry.accountId,
1219
1351
  reason: 'no-active-connection',
1352
+ recentInboundReachable,
1220
1353
  })}`,
1221
1354
  { debugOnly: true },
1222
1355
  );
@@ -1230,18 +1363,26 @@ class BncrBridgeRuntime {
1230
1363
  };
1231
1364
 
1232
1365
  ctx.broadcastToConnIds(BNCR_PUSH_EVENT, payload, connIds);
1366
+ entry.lastPushAt = now();
1367
+ entry.lastPushConnId =
1368
+ owner?.connId || (connIds.size === 1 ? Array.from(connIds)[0] : undefined);
1369
+ entry.lastPushClientId = owner?.clientId;
1370
+ this.outbox.set(entry.messageId, entry);
1233
1371
  this.logInfo(
1234
1372
  'outbox',
1235
1373
  `push-ok ${JSON.stringify({
1236
1374
  messageId: entry.messageId,
1237
1375
  accountId: entry.accountId,
1238
1376
  connIds: Array.from(connIds),
1377
+ ownerConnId: entry.lastPushConnId || '',
1378
+ ownerClientId: entry.lastPushClientId || '',
1379
+ recentInboundReachable,
1239
1380
  event: BNCR_PUSH_EVENT,
1240
1381
  })}`,
1241
1382
  { debugOnly: true },
1242
1383
  );
1243
- this.lastOutboundByAccount.set(entry.accountId, now());
1244
- this.markActivity(entry.accountId);
1384
+ this.lastOutboundByAccount.set(entry.accountId, entry.lastPushAt);
1385
+ this.markActivity(entry.accountId, entry.lastPushAt);
1245
1386
  this.scheduleSave();
1246
1387
  return true;
1247
1388
  } catch (error) {
@@ -1269,6 +1410,42 @@ class BncrBridgeRuntime {
1269
1410
  }, delay);
1270
1411
  }
1271
1412
 
1413
+ private isOutboundAckRequired(accountId?: string) {
1414
+ try {
1415
+ const cfg = this.api.runtime.config.current();
1416
+ const channelCfg = (cfg as any)?.channels?.[CHANNEL_ID];
1417
+ const accountCfg =
1418
+ accountId && channelCfg?.accounts && typeof channelCfg.accounts === 'object'
1419
+ ? (channelCfg.accounts as Record<string, any>)[normalizeAccountId(accountId)]
1420
+ : null;
1421
+ const scoped = accountCfg?.outboundRequireAck;
1422
+ const global = channelCfg?.outboundRequireAck;
1423
+ if (typeof scoped === 'boolean') return scoped;
1424
+ if (typeof global === 'boolean') return global;
1425
+ return true;
1426
+ } catch {
1427
+ return true;
1428
+ }
1429
+ }
1430
+
1431
+ private buildRuntimeFlags(accountId?: string) {
1432
+ let ackPolicySource: 'channel' | 'default' = 'default';
1433
+ try {
1434
+ const cfg = this.api.runtime.config.current();
1435
+ const global = (cfg as any)?.channels?.[CHANNEL_ID]?.outboundRequireAck;
1436
+ if (typeof global === 'boolean') ackPolicySource = 'channel';
1437
+ } catch {
1438
+ // keep default source
1439
+ }
1440
+ return {
1441
+ outboundRequireAck: this.isOutboundAckRequired(accountId),
1442
+ ackPolicySource,
1443
+ messageAckTimeoutMs: PUSH_ACK_TIMEOUT_MS,
1444
+ fileAckTimeoutMs: FILE_ACK_TIMEOUT_MS,
1445
+ debugVerbose: BNCR_DEBUG_VERBOSE,
1446
+ };
1447
+ }
1448
+
1272
1449
  private async flushPushQueue(accountId?: string): Promise<void> {
1273
1450
  const filterAcc = accountId ? normalizeAccountId(accountId) : null;
1274
1451
  const targetAccounts = filterAcc
@@ -1294,12 +1471,14 @@ class BncrBridgeRuntime {
1294
1471
  for (const acc of targetAccounts) {
1295
1472
  if (!acc || this.pushDrainRunningAccounts.has(acc)) continue;
1296
1473
  const online = this.isOnline(acc);
1474
+ const recentInboundReachable = this.hasRecentInboundReachability(acc);
1297
1475
  this.logInfo(
1298
1476
  'outbox',
1299
1477
  `online ${JSON.stringify({
1300
1478
  bridge: this.bridgeId,
1301
1479
  accountId: acc,
1302
1480
  online,
1481
+ recentInboundReachable,
1303
1482
  connections: Array.from(this.connections.values()).map((c) => ({
1304
1483
  accountId: c.accountId,
1305
1484
  connId: c.connId,
@@ -1328,26 +1507,48 @@ class BncrBridgeRuntime {
1328
1507
  break;
1329
1508
  }
1330
1509
 
1331
- const onlineNow = this.isOnline(acc);
1510
+ const onlineNow = this.isOnline(acc) || this.hasRecentInboundReachability(acc);
1332
1511
  const pushed = this.tryPushEntry(entry);
1333
1512
  if (pushed) {
1334
- if (onlineNow) {
1335
- await this.waitForOutbound(acc, PUSH_ACK_TIMEOUT_MS);
1513
+ const requireAck = this.isOutboundAckRequired(acc);
1514
+ let ackResult: 'acked' | 'timeout' = requireAck ? 'timeout' : 'acked';
1515
+ if (onlineNow && requireAck) {
1516
+ ackResult = await this.waitForMessageAck(entry.messageId, PUSH_ACK_TIMEOUT_MS);
1336
1517
  }
1337
1518
 
1519
+ this.logInfo(
1520
+ 'outbox',
1521
+ `ack ${JSON.stringify({
1522
+ messageId: entry.messageId,
1523
+ accountId: entry.accountId,
1524
+ requireAck,
1525
+ ackResult,
1526
+ onlineNow,
1527
+ })}`,
1528
+ { debugOnly: true },
1529
+ );
1530
+
1338
1531
  if (!this.outbox.has(entry.messageId)) {
1339
1532
  await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
1340
1533
  continue;
1341
1534
  }
1342
1535
 
1536
+ if (onlineNow && (!requireAck || ackResult !== 'timeout')) {
1537
+ await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
1538
+ continue;
1539
+ }
1540
+
1343
1541
  entry.retryCount += 1;
1344
1542
  entry.lastAttemptAt = now();
1345
1543
  if (entry.retryCount > MAX_RETRY) {
1346
- this.moveToDeadLetter(entry, entry.lastError || 'push-ack-timeout');
1544
+ this.moveToDeadLetter(
1545
+ entry,
1546
+ entry.lastError || (requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed'),
1547
+ );
1347
1548
  continue;
1348
1549
  }
1349
1550
  entry.nextAttemptAt = now() + backoffMs(entry.retryCount);
1350
- entry.lastError = entry.lastError || 'push-ack-timeout';
1551
+ entry.lastError = requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed';
1351
1552
  this.outbox.set(entry.messageId, entry);
1352
1553
  this.scheduleSave();
1353
1554
 
@@ -1387,29 +1588,18 @@ class BncrBridgeRuntime {
1387
1588
  if (globalNextDelay != null) this.schedulePushDrain(globalNextDelay);
1388
1589
  }
1389
1590
 
1390
- private async waitForOutbound(accountId: string, waitMs: number): Promise<void> {
1391
- const key = normalizeAccountId(accountId);
1591
+ private async waitForMessageAck(messageId: string, waitMs: number): Promise<'acked' | 'timeout'> {
1592
+ const key = asString(messageId).trim();
1392
1593
  const timeoutMs = Math.max(0, Math.min(waitMs, 25_000));
1393
- if (!timeoutMs) return;
1594
+ if (!key || !timeoutMs) return 'timeout';
1394
1595
 
1395
- await new Promise<void>((resolve) => {
1596
+ return await new Promise<'acked' | 'timeout'>((resolve) => {
1396
1597
  const timer = setTimeout(() => {
1397
- const arr = this.waiters.get(key) || [];
1398
- this.waiters.set(
1399
- key,
1400
- arr.filter((fn) => fn !== done),
1401
- );
1402
- resolve();
1598
+ this.messageAckWaiters.delete(key);
1599
+ resolve('timeout');
1403
1600
  }, timeoutMs);
1404
1601
 
1405
- const done = () => {
1406
- clearTimeout(timer);
1407
- resolve();
1408
- };
1409
-
1410
- const arr = this.waiters.get(key) || [];
1411
- arr.push(done);
1412
- this.waiters.set(key, arr);
1602
+ this.messageAckWaiters.set(key, { resolve, timer });
1413
1603
  });
1414
1604
  }
1415
1605
 
@@ -1710,7 +1900,17 @@ class BncrBridgeRuntime {
1710
1900
  if (!connIds.size || !this.gatewayContext) {
1711
1901
  throw new Error(`no active bncr connection for account=${accountId}`);
1712
1902
  }
1713
- this.gatewayContext.broadcastToConnIds(event, payload, connIds);
1903
+ const normalizedEvent =
1904
+ event === 'bncr.file.init'
1905
+ ? BNCR_FILE_INIT_EVENT
1906
+ : event === 'bncr.file.chunk'
1907
+ ? BNCR_FILE_CHUNK_EVENT
1908
+ : event === 'bncr.file.complete'
1909
+ ? BNCR_FILE_COMPLETE_EVENT
1910
+ : event === 'bncr.file.abort'
1911
+ ? BNCR_FILE_ABORT_EVENT
1912
+ : event;
1913
+ this.gatewayContext.broadcastToConnIds(normalizedEvent, payload, connIds);
1714
1914
  }
1715
1915
 
1716
1916
  private resolveInboundFileType(mimeType: string, fileName: string): string {
@@ -1877,7 +2077,6 @@ class BncrBridgeRuntime {
1877
2077
  this.logOutboundSummary(entry);
1878
2078
  this.outbox.set(entry.messageId, entry);
1879
2079
  this.scheduleSave();
1880
- this.wakeAccountWaiters(entry.accountId);
1881
2080
  this.flushPushQueue(entry.accountId);
1882
2081
  }
1883
2082
 
@@ -1889,6 +2088,7 @@ class BncrBridgeRuntime {
1889
2088
  this.deadLetter.push(dead);
1890
2089
  if (this.deadLetter.length > 1000) this.deadLetter = this.deadLetter.slice(-1000);
1891
2090
  this.outbox.delete(entry.messageId);
2091
+ this.resolveMessageAck(entry.messageId, 'timeout');
1892
2092
  this.scheduleSave();
1893
2093
  }
1894
2094
 
@@ -2064,6 +2264,7 @@ class BncrBridgeRuntime {
2064
2264
  const totalChunks = Math.ceil(size / chunkSize);
2065
2265
  const fileSha256 = createHash('sha256').update(loaded.buffer).digest('hex');
2066
2266
 
2267
+ const owner = this.resolveOutboxPushOwner(params.accountId);
2067
2268
  const st: FileSendTransferState = {
2068
2269
  transferId,
2069
2270
  accountId: normalizeAccountId(params.accountId),
@@ -2079,11 +2280,13 @@ class BncrBridgeRuntime {
2079
2280
  status: 'init',
2080
2281
  ackedChunks: new Set(),
2081
2282
  failedChunks: new Map(),
2283
+ ownerConnId: owner?.connId,
2284
+ ownerClientId: owner?.clientId,
2082
2285
  };
2083
2286
  this.fileSendTransfers.set(transferId, st);
2084
2287
 
2085
2288
  ctx.broadcastToConnIds(
2086
- 'bncr.file.init',
2289
+ BNCR_FILE_INIT_EVENT,
2087
2290
  {
2088
2291
  transferId,
2089
2292
  direction: 'oc2bncr',
@@ -2113,7 +2316,7 @@ class BncrBridgeRuntime {
2113
2316
  let lastErr: unknown = null;
2114
2317
  for (let attempt = 1; attempt <= 3; attempt++) {
2115
2318
  ctx.broadcastToConnIds(
2116
- 'bncr.file.chunk',
2319
+ BNCR_FILE_CHUNK_EVENT,
2117
2320
  {
2118
2321
  transferId,
2119
2322
  chunkIndex: idx,
@@ -2145,7 +2348,7 @@ class BncrBridgeRuntime {
2145
2348
  st.error = String((lastErr as any)?.message || lastErr || `chunk-${idx}-failed`);
2146
2349
  this.fileSendTransfers.set(transferId, st);
2147
2350
  ctx.broadcastToConnIds(
2148
- 'bncr.file.abort',
2351
+ BNCR_FILE_ABORT_EVENT,
2149
2352
  {
2150
2353
  transferId,
2151
2354
  reason: st.error,
@@ -2158,7 +2361,7 @@ class BncrBridgeRuntime {
2158
2361
  }
2159
2362
 
2160
2363
  ctx.broadcastToConnIds(
2161
- 'bncr.file.complete',
2364
+ BNCR_FILE_COMPLETE_EVENT,
2162
2365
  {
2163
2366
  transferId,
2164
2367
  ts: now(),
@@ -2316,6 +2519,11 @@ class BncrBridgeRuntime {
2316
2519
  pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
2317
2520
  deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
2318
2521
  diagnostics: this.buildExtendedDiagnostics(accountId),
2522
+ runtimeFlags: this.buildRuntimeFlags(accountId),
2523
+ waiters: {
2524
+ messageAck: this.messageAckWaiters.size,
2525
+ fileAck: this.fileAckWaiters.size,
2526
+ },
2319
2527
  leaseId: lease.leaseId,
2320
2528
  connectionEpoch: lease.connectionEpoch,
2321
2529
  protocolVersion: 2,
@@ -2334,13 +2542,9 @@ class BncrBridgeRuntime {
2334
2542
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2335
2543
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2336
2544
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2337
- this.rememberGatewayContext(context);
2338
- this.markSeen(accountId, connId, clientId);
2339
- this.observeLease('ack', params ?? {});
2340
- this.lastAckAtGlobal = now();
2341
- this.incrementCounter(this.ackEventsByAccount, accountId);
2342
-
2343
2545
  const messageId = asString(params?.messageId || '').trim();
2546
+ const staleObserved = this.observeLease('ack', params ?? {});
2547
+
2344
2548
  this.logInfo(
2345
2549
  'outbox',
2346
2550
  `ack ${JSON.stringify({
@@ -2349,6 +2553,7 @@ class BncrBridgeRuntime {
2349
2553
  ok: params?.ok !== false,
2350
2554
  fatal: params?.fatal === true,
2351
2555
  error: asString(params?.error || ''),
2556
+ stale: staleObserved.stale,
2352
2557
  })}`,
2353
2558
  { debugOnly: true },
2354
2559
  );
@@ -2359,7 +2564,7 @@ class BncrBridgeRuntime {
2359
2564
 
2360
2565
  const entry = this.outbox.get(messageId);
2361
2566
  if (!entry) {
2362
- respond(true, { ok: true, message: 'already-acked-or-missing' });
2567
+ respond(true, { ok: true, message: 'already-acked-or-missing', stale: staleObserved.stale });
2363
2568
  return;
2364
2569
  }
2365
2570
 
@@ -2368,20 +2573,52 @@ class BncrBridgeRuntime {
2368
2573
  return;
2369
2574
  }
2370
2575
 
2576
+ if (staleObserved.stale) {
2577
+ const sameConn = !!entry.lastPushConnId && entry.lastPushConnId === connId;
2578
+ const sameClient =
2579
+ !entry.lastPushConnId &&
2580
+ !!entry.lastPushClientId &&
2581
+ !!clientId &&
2582
+ entry.lastPushClientId === clientId;
2583
+ if (!(sameConn || sameClient)) {
2584
+ this.logWarn(
2585
+ 'stale',
2586
+ `ignore kind=ack accountId=${accountId} connId=${connId} clientId=${clientId || '-'} messageId=${messageId} reason=owner-mismatch lastPushConnId=${entry.lastPushConnId || '-'} lastPushClientId=${entry.lastPushClientId || '-'}`,
2587
+ { debugOnly: true },
2588
+ );
2589
+ respond(true, { ok: true, stale: true, ignored: true });
2590
+ return;
2591
+ }
2592
+ } else {
2593
+ this.rememberGatewayContext(context);
2594
+ this.markSeen(accountId, connId, clientId);
2595
+ }
2596
+ this.lastAckAtGlobal = now();
2597
+ this.incrementCounter(this.ackEventsByAccount, accountId);
2598
+
2371
2599
  const ok = params?.ok !== false;
2372
2600
  const fatal = params?.fatal === true;
2373
2601
 
2374
2602
  if (ok) {
2375
2603
  this.outbox.delete(messageId);
2376
2604
  this.scheduleSave();
2377
- this.wakeAccountWaiters(accountId);
2378
- respond(true, { ok: true });
2605
+ this.resolveMessageAck(messageId, 'acked');
2606
+ respond(
2607
+ true,
2608
+ staleObserved.stale ? { ok: true, stale: true, staleAccepted: true } : { ok: true },
2609
+ );
2610
+ this.flushPushQueue(accountId);
2379
2611
  return;
2380
2612
  }
2381
2613
 
2382
2614
  if (fatal) {
2383
2615
  this.moveToDeadLetter(entry, asString(params?.error || 'fatal-ack'));
2384
- respond(true, { ok: true, movedToDeadLetter: true });
2616
+ respond(
2617
+ true,
2618
+ staleObserved.stale
2619
+ ? { ok: true, movedToDeadLetter: true, stale: true, staleAccepted: true }
2620
+ : { ok: true, movedToDeadLetter: true },
2621
+ );
2385
2622
  return;
2386
2623
  }
2387
2624
 
@@ -2390,7 +2627,12 @@ class BncrBridgeRuntime {
2390
2627
  this.outbox.set(messageId, entry);
2391
2628
  this.scheduleSave();
2392
2629
 
2393
- respond(true, { ok: true, willRetry: true });
2630
+ respond(
2631
+ true,
2632
+ staleObserved.stale
2633
+ ? { ok: true, willRetry: true, stale: true, staleAccepted: true }
2634
+ : { ok: true, willRetry: true },
2635
+ );
2394
2636
  };
2395
2637
 
2396
2638
  handleActivity = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
@@ -2398,7 +2640,18 @@ class BncrBridgeRuntime {
2398
2640
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2399
2641
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2400
2642
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2401
- this.observeLease('activity', params ?? {});
2643
+ if (
2644
+ this.shouldIgnoreStaleEvent({
2645
+ kind: 'activity',
2646
+ payload: params ?? {},
2647
+ accountId,
2648
+ connId,
2649
+ clientId,
2650
+ })
2651
+ ) {
2652
+ respond(true, { accountId, ok: true, event: 'activity', stale: true, ignored: true });
2653
+ return;
2654
+ }
2402
2655
  this.lastActivityAtGlobal = now();
2403
2656
  this.logInfo(
2404
2657
  'activity',
@@ -2426,11 +2679,12 @@ class BncrBridgeRuntime {
2426
2679
  deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
2427
2680
  now: now(),
2428
2681
  });
2682
+ this.flushPushQueue(accountId);
2429
2683
  };
2430
2684
 
2431
2685
  handleDiagnostics = async ({ params, respond }: GatewayRequestHandlerOptions) => {
2432
2686
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2433
- const cfg = await this.api.runtime.config.loadConfig();
2687
+ const cfg = this.api.runtime.config.current();
2434
2688
  const runtime = this.getAccountRuntimeSnapshot(accountId);
2435
2689
  const diagnostics = this.buildExtendedDiagnostics(accountId);
2436
2690
  const permissions = buildBncrPermissionSummary(cfg ?? {});
@@ -2455,6 +2709,11 @@ class BncrBridgeRuntime {
2455
2709
  accountId,
2456
2710
  runtime,
2457
2711
  diagnostics,
2712
+ runtimeFlags: this.buildRuntimeFlags(accountId),
2713
+ waiters: {
2714
+ messageAck: this.messageAckWaiters.size,
2715
+ fileAck: this.fileAckWaiters.size,
2716
+ },
2458
2717
  permissions,
2459
2718
  probe,
2460
2719
  now: now(),
@@ -2465,9 +2724,20 @@ class BncrBridgeRuntime {
2465
2724
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2466
2725
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2467
2726
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2727
+ if (
2728
+ this.shouldIgnoreStaleEvent({
2729
+ kind: 'file.init',
2730
+ payload: params ?? {},
2731
+ accountId,
2732
+ connId,
2733
+ clientId,
2734
+ })
2735
+ ) {
2736
+ respond(true, { ok: true, stale: true, ignored: true });
2737
+ return;
2738
+ }
2468
2739
  this.rememberGatewayContext(context);
2469
2740
  this.markSeen(accountId, connId, clientId);
2470
- this.observeLease('file.init', params ?? {});
2471
2741
  this.markActivity(accountId);
2472
2742
 
2473
2743
  const transferId = asString(params?.transferId || '').trim();
@@ -2523,6 +2793,8 @@ class BncrBridgeRuntime {
2523
2793
  status: 'init',
2524
2794
  bufferByChunk: new Map(),
2525
2795
  receivedChunks: new Set(),
2796
+ ownerConnId: connId,
2797
+ ownerClientId: clientId,
2526
2798
  });
2527
2799
 
2528
2800
  respond(true, {
@@ -2536,10 +2808,6 @@ class BncrBridgeRuntime {
2536
2808
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2537
2809
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2538
2810
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2539
- this.rememberGatewayContext(context);
2540
- this.markSeen(accountId, connId, clientId);
2541
- this.observeLease('file.chunk', params ?? {});
2542
- this.markActivity(accountId);
2543
2811
 
2544
2812
  const transferId = asString(params?.transferId || '').trim();
2545
2813
  const chunkIndex = Number(params?.chunkIndex ?? -1);
@@ -2559,6 +2827,30 @@ class BncrBridgeRuntime {
2559
2827
  return;
2560
2828
  }
2561
2829
 
2830
+ const staleObserved = this.observeLease('file.chunk', params ?? {});
2831
+ if (staleObserved.stale) {
2832
+ if (
2833
+ !this.matchesTransferOwner({
2834
+ ownerConnId: st.ownerConnId,
2835
+ ownerClientId: st.ownerClientId,
2836
+ connId,
2837
+ clientId,
2838
+ })
2839
+ ) {
2840
+ this.logWarn(
2841
+ 'stale',
2842
+ `ignore kind=file.chunk accountId=${accountId} connId=${connId} clientId=${clientId || '-'} transferId=${transferId} reason=owner-mismatch ownerConnId=${st.ownerConnId || '-'} ownerClientId=${st.ownerClientId || '-'}`,
2843
+ { debugOnly: true },
2844
+ );
2845
+ respond(true, { ok: true, stale: true, ignored: true });
2846
+ return;
2847
+ }
2848
+ } else {
2849
+ this.rememberGatewayContext(context);
2850
+ this.markSeen(accountId, connId, clientId);
2851
+ this.markActivity(accountId);
2852
+ }
2853
+
2562
2854
  try {
2563
2855
  const buf = Buffer.from(base64, 'base64');
2564
2856
  if (size > 0 && buf.length !== size) {
@@ -2573,14 +2865,28 @@ class BncrBridgeRuntime {
2573
2865
  st.status = 'transferring';
2574
2866
  this.fileRecvTransfers.set(transferId, st);
2575
2867
 
2576
- respond(true, {
2577
- ok: true,
2578
- transferId,
2579
- chunkIndex,
2580
- offset,
2581
- received: st.receivedChunks.size,
2582
- totalChunks: st.totalChunks,
2583
- });
2868
+ respond(
2869
+ true,
2870
+ staleObserved.stale
2871
+ ? {
2872
+ ok: true,
2873
+ transferId,
2874
+ chunkIndex,
2875
+ offset,
2876
+ received: st.receivedChunks.size,
2877
+ totalChunks: st.totalChunks,
2878
+ stale: true,
2879
+ staleAccepted: true,
2880
+ }
2881
+ : {
2882
+ ok: true,
2883
+ transferId,
2884
+ chunkIndex,
2885
+ offset,
2886
+ received: st.receivedChunks.size,
2887
+ totalChunks: st.totalChunks,
2888
+ },
2889
+ );
2584
2890
  } catch (error) {
2585
2891
  respond(false, { error: String((error as any)?.message || error || 'chunk invalid') });
2586
2892
  }
@@ -2595,10 +2901,6 @@ class BncrBridgeRuntime {
2595
2901
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2596
2902
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2597
2903
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2598
- this.rememberGatewayContext(context);
2599
- this.markSeen(accountId, connId, clientId);
2600
- this.observeLease('file.complete', params ?? {});
2601
- this.markActivity(accountId);
2602
2904
 
2603
2905
  const transferId = asString(params?.transferId || '').trim();
2604
2906
  if (!transferId) {
@@ -2612,6 +2914,30 @@ class BncrBridgeRuntime {
2612
2914
  return;
2613
2915
  }
2614
2916
 
2917
+ const staleObserved = this.observeLease('file.complete', params ?? {});
2918
+ if (staleObserved.stale) {
2919
+ if (
2920
+ !this.matchesTransferOwner({
2921
+ ownerConnId: st.ownerConnId,
2922
+ ownerClientId: st.ownerClientId,
2923
+ connId,
2924
+ clientId,
2925
+ })
2926
+ ) {
2927
+ this.logWarn(
2928
+ 'stale',
2929
+ `ignore kind=file.complete accountId=${accountId} connId=${connId} clientId=${clientId || '-'} transferId=${transferId} reason=owner-mismatch ownerConnId=${st.ownerConnId || '-'} ownerClientId=${st.ownerClientId || '-'}`,
2930
+ { debugOnly: true },
2931
+ );
2932
+ respond(true, { ok: true, stale: true, ignored: true });
2933
+ return;
2934
+ }
2935
+ } else {
2936
+ this.rememberGatewayContext(context);
2937
+ this.markSeen(accountId, connId, clientId);
2938
+ this.markActivity(accountId);
2939
+ }
2940
+
2615
2941
  try {
2616
2942
  if (st.receivedChunks.size < st.totalChunks) {
2617
2943
  throw new Error(
@@ -2642,15 +2968,30 @@ class BncrBridgeRuntime {
2642
2968
  st.status = 'completed';
2643
2969
  this.fileRecvTransfers.set(transferId, st);
2644
2970
 
2645
- respond(true, {
2646
- ok: true,
2647
- transferId,
2648
- path: saved.path,
2649
- size: merged.length,
2650
- fileName: st.fileName,
2651
- mimeType: st.mimeType,
2652
- fileSha256: digest,
2653
- });
2971
+ respond(
2972
+ true,
2973
+ staleObserved.stale
2974
+ ? {
2975
+ ok: true,
2976
+ transferId,
2977
+ path: saved.path,
2978
+ size: merged.length,
2979
+ fileName: st.fileName,
2980
+ mimeType: st.mimeType,
2981
+ fileSha256: digest,
2982
+ stale: true,
2983
+ staleAccepted: true,
2984
+ }
2985
+ : {
2986
+ ok: true,
2987
+ transferId,
2988
+ path: saved.path,
2989
+ size: merged.length,
2990
+ fileName: st.fileName,
2991
+ mimeType: st.mimeType,
2992
+ fileSha256: digest,
2993
+ },
2994
+ );
2654
2995
  } catch (error) {
2655
2996
  st.status = 'aborted';
2656
2997
  st.error = String((error as any)?.message || error || 'complete failed');
@@ -2663,10 +3004,6 @@ class BncrBridgeRuntime {
2663
3004
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2664
3005
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2665
3006
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2666
- this.rememberGatewayContext(context);
2667
- this.markSeen(accountId, connId, clientId);
2668
- this.observeLease('file.abort', params ?? {});
2669
- this.markActivity(accountId);
2670
3007
 
2671
3008
  const transferId = asString(params?.transferId || '').trim();
2672
3009
  if (!transferId) {
@@ -2680,24 +3017,56 @@ class BncrBridgeRuntime {
2680
3017
  return;
2681
3018
  }
2682
3019
 
3020
+ const staleObserved = this.observeLease('file.abort', params ?? {});
3021
+ if (staleObserved.stale) {
3022
+ if (
3023
+ !this.matchesTransferOwner({
3024
+ ownerConnId: st.ownerConnId,
3025
+ ownerClientId: st.ownerClientId,
3026
+ connId,
3027
+ clientId,
3028
+ })
3029
+ ) {
3030
+ this.logWarn(
3031
+ 'stale',
3032
+ `ignore kind=file.abort accountId=${accountId} connId=${connId} clientId=${clientId || '-'} transferId=${transferId} reason=owner-mismatch ownerConnId=${st.ownerConnId || '-'} ownerClientId=${st.ownerClientId || '-'}`,
3033
+ { debugOnly: true },
3034
+ );
3035
+ respond(true, { ok: true, stale: true, ignored: true });
3036
+ return;
3037
+ }
3038
+ } else {
3039
+ this.rememberGatewayContext(context);
3040
+ this.markSeen(accountId, connId, clientId);
3041
+ this.markActivity(accountId);
3042
+ }
3043
+
2683
3044
  st.status = 'aborted';
2684
3045
  st.error = asString(params?.reason || 'aborted');
2685
3046
  this.fileRecvTransfers.set(transferId, st);
2686
3047
 
2687
- respond(true, {
2688
- ok: true,
2689
- transferId,
2690
- status: 'aborted',
2691
- });
3048
+ respond(
3049
+ true,
3050
+ staleObserved.stale
3051
+ ? {
3052
+ ok: true,
3053
+ transferId,
3054
+ status: 'aborted',
3055
+ stale: true,
3056
+ staleAccepted: true,
3057
+ }
3058
+ : {
3059
+ ok: true,
3060
+ transferId,
3061
+ status: 'aborted',
3062
+ },
3063
+ );
2692
3064
  };
2693
3065
 
2694
3066
  handleFileAck = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2695
3067
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2696
3068
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2697
3069
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2698
- this.rememberGatewayContext(context);
2699
- this.markSeen(accountId, connId, clientId);
2700
- this.markActivity(accountId);
2701
3070
 
2702
3071
  const transferId = asString(params?.transferId || '').trim();
2703
3072
  const stage = asString(params?.stage || '').trim();
@@ -2710,6 +3079,34 @@ class BncrBridgeRuntime {
2710
3079
  }
2711
3080
 
2712
3081
  const st = this.fileSendTransfers.get(transferId);
3082
+ const staleKind =
3083
+ stage === 'init'
3084
+ ? 'file.init'
3085
+ : stage === 'chunk'
3086
+ ? 'file.chunk'
3087
+ : stage === 'abort'
3088
+ ? 'file.abort'
3089
+ : 'file.complete';
3090
+ const staleObserved = this.observeLease(staleKind, params ?? {});
3091
+ if (staleObserved.stale) {
3092
+ const sameConn = !!st?.ownerConnId && st.ownerConnId === connId;
3093
+ const sameClient =
3094
+ !st?.ownerConnId && !!st?.ownerClientId && !!clientId && st.ownerClientId === clientId;
3095
+ if (!(sameConn || sameClient)) {
3096
+ this.logWarn(
3097
+ 'stale',
3098
+ `ignore kind=file.ack accountId=${accountId} connId=${connId} clientId=${clientId || '-'} transferId=${transferId} stage=${stage} reason=owner-mismatch ownerConnId=${st?.ownerConnId || '-'} ownerClientId=${st?.ownerClientId || '-'}`,
3099
+ { debugOnly: true },
3100
+ );
3101
+ respond(true, { ok: true, stale: true, ignored: true });
3102
+ return;
3103
+ }
3104
+ } else {
3105
+ this.rememberGatewayContext(context);
3106
+ this.markSeen(accountId, connId, clientId);
3107
+ this.markActivity(accountId);
3108
+ }
3109
+
2713
3110
  if (st) {
2714
3111
  if (!ok) {
2715
3112
  const code = asString(params?.errorCode || 'ACK_FAILED');
@@ -2746,12 +3143,24 @@ class BncrBridgeRuntime {
2746
3143
  ok,
2747
3144
  });
2748
3145
 
2749
- respond(true, {
2750
- ok: true,
2751
- transferId,
2752
- stage,
2753
- state: st?.status || 'late',
2754
- });
3146
+ respond(
3147
+ true,
3148
+ staleObserved.stale
3149
+ ? {
3150
+ ok: true,
3151
+ transferId,
3152
+ stage,
3153
+ state: st?.status || 'late',
3154
+ stale: true,
3155
+ staleAccepted: true,
3156
+ }
3157
+ : {
3158
+ ok: true,
3159
+ transferId,
3160
+ stage,
3161
+ state: st?.status || 'late',
3162
+ },
3163
+ );
2755
3164
  };
2756
3165
 
2757
3166
  handleInbound = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
@@ -2777,9 +3186,26 @@ class BncrBridgeRuntime {
2777
3186
  } = parsed;
2778
3187
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2779
3188
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
3189
+ if (
3190
+ this.shouldIgnoreStaleEvent({
3191
+ kind: 'inbound',
3192
+ payload: params ?? {},
3193
+ accountId,
3194
+ connId,
3195
+ clientId,
3196
+ })
3197
+ ) {
3198
+ respond(true, {
3199
+ accepted: false,
3200
+ stale: true,
3201
+ ignored: true,
3202
+ accountId,
3203
+ msgId: msgId ?? null,
3204
+ });
3205
+ return;
3206
+ }
2780
3207
  this.rememberGatewayContext(context);
2781
3208
  this.markSeen(accountId, connId, clientId);
2782
- this.observeLease('inbound', params ?? {});
2783
3209
  this.markActivity(accountId);
2784
3210
  this.lastInboundAtGlobal = now();
2785
3211
  this.incrementCounter(this.inboundEventsByAccount, accountId);
@@ -2798,7 +3224,7 @@ class BncrBridgeRuntime {
2798
3224
  return;
2799
3225
  }
2800
3226
 
2801
- const cfg = await this.api.runtime.config.loadConfig();
3227
+ const cfg = this.api.runtime.config.current();
2802
3228
  const gate = checkBncrMessageGate({
2803
3229
  parsed,
2804
3230
  cfg,
@@ -2863,6 +3289,7 @@ class BncrBridgeRuntime {
2863
3289
  msgId: msgId ?? null,
2864
3290
  taskKey: extracted.taskKey ?? null,
2865
3291
  });
3292
+ this.flushPushQueue(accountId);
2866
3293
 
2867
3294
  void dispatchBncrInbound({
2868
3295
  api: this.api,