@xmoxmo/bncr 0.1.1 → 0.1.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/README.md CHANGED
@@ -187,6 +187,18 @@ npm pack
187
187
  - `npm test`:跑回归测试
188
188
  - `npm run selfcheck`:检查插件骨架是否完整
189
189
  - `npm pack`:确认当前版本可正常打包
190
+ - `npm run check-register-drift -- --duration-sec 300 --interval-sec 15`:静置采样 `bncr.diagnostics`,观察 `registerCount / apiGeneration / postWarmupRegisterCount` 是否在 warmup 后继续增长
191
+
192
+ 示例输出重点:
193
+
194
+ - `delta.registerCount`
195
+ - `delta.apiGeneration`
196
+ - `delta.postWarmupRegisterCount`
197
+ - `historicalWarmupExternalDrift`
198
+ - `newDriftDuringWindow`
199
+ - `last.postWarmupRegisterCount`
200
+ - `last.unexpectedRegisterAfterWarmup`
201
+ - `driftDetected`
190
202
 
191
203
  ---
192
204
 
package/index.ts CHANGED
@@ -20,7 +20,23 @@ type LoadedRuntime = {
20
20
  createBncrChannelPlugin: ChannelModule['createBncrChannelPlugin'];
21
21
  };
22
22
 
23
+ const BNCR_REGISTER_META = Symbol.for('bncr.register.meta');
24
+
25
+ type RegisterMeta = {
26
+ service?: boolean;
27
+ channel?: boolean;
28
+ methods?: Set<string>;
29
+ apiInstanceId?: string;
30
+ registryFingerprint?: string;
31
+ };
32
+
33
+ type OpenClawPluginApiWithMeta = OpenClawPluginApi & {
34
+ [BNCR_REGISTER_META]?: RegisterMeta;
35
+ };
36
+
23
37
  let runtime: LoadedRuntime | null = null;
38
+ const identityIds = new WeakMap<object, string>();
39
+ let identitySeq = 0;
24
40
 
25
41
  const tryExec = (command: string, args: string[]) => {
26
42
  try {
@@ -188,11 +204,65 @@ const loadRuntimeSync = (): LoadedRuntime => {
188
204
  }
189
205
  };
190
206
 
207
+ const getIdentityId = (obj: object, prefix: string) => {
208
+ const existing = identityIds.get(obj);
209
+ if (existing) return existing;
210
+ const next = `${prefix}_${++identitySeq}`;
211
+ identityIds.set(obj, next);
212
+ return next;
213
+ };
214
+
215
+ const getRegistryFingerprint = (api: OpenClawPluginApi) => {
216
+ const serviceId = getIdentityId(api.registerService as object, 'svc');
217
+ const channelId = getIdentityId(api.registerChannel as object, 'chn');
218
+ const methodId = getIdentityId(api.registerGatewayMethod as object, 'mth');
219
+ return `${serviceId}:${channelId}:${methodId}`;
220
+ };
221
+
222
+ const getRegisterMeta = (api: OpenClawPluginApi): RegisterMeta => {
223
+ const host = api as OpenClawPluginApiWithMeta;
224
+ if (!host[BNCR_REGISTER_META]) {
225
+ host[BNCR_REGISTER_META] = { methods: new Set<string>() };
226
+ }
227
+ if (!host[BNCR_REGISTER_META]!.methods) {
228
+ host[BNCR_REGISTER_META]!.methods = new Set<string>();
229
+ }
230
+ if (!host[BNCR_REGISTER_META]!.apiInstanceId) {
231
+ host[BNCR_REGISTER_META]!.apiInstanceId = getIdentityId(api as object, 'api');
232
+ }
233
+ if (!host[BNCR_REGISTER_META]!.registryFingerprint) {
234
+ host[BNCR_REGISTER_META]!.registryFingerprint = getRegistryFingerprint(api);
235
+ }
236
+ return host[BNCR_REGISTER_META]!;
237
+ };
238
+
239
+ const ensureGatewayMethodRegistered = (
240
+ api: OpenClawPluginApi,
241
+ name: string,
242
+ handler: (opts: any) => any,
243
+ debugLog: (...args: any[]) => void,
244
+ ) => {
245
+ const meta = getRegisterMeta(api);
246
+ if (meta.methods?.has(name)) {
247
+ debugLog(`bncr register method skip ${name} (already registered on this api)`);
248
+ return;
249
+ }
250
+ api.registerGatewayMethod(name, handler);
251
+ meta.methods?.add(name);
252
+ debugLog(`bncr register method ok ${name}`);
253
+ };
254
+
191
255
  const getBridgeSingleton = (api: OpenClawPluginApi) => {
192
256
  const loaded = loadRuntimeSync();
193
257
  const g = globalThis as typeof globalThis & { __bncrBridge?: BridgeSingleton };
194
- if (!g.__bncrBridge) g.__bncrBridge = loaded.createBncrBridge(api);
195
- return { bridge: g.__bncrBridge, runtime: loaded };
258
+ let created = false;
259
+ if (!g.__bncrBridge) {
260
+ g.__bncrBridge = loaded.createBncrBridge(api);
261
+ created = true;
262
+ } else {
263
+ g.__bncrBridge.bindApi?.(api);
264
+ }
265
+ return { bridge: g.__bncrBridge, runtime: loaded, created };
196
266
  };
197
267
 
198
268
  const plugin = {
@@ -201,13 +271,22 @@ const plugin = {
201
271
  description: 'Bncr channel plugin',
202
272
  configSchema: BncrConfigSchema,
203
273
  register(api: OpenClawPluginApi) {
204
- const { bridge, runtime } = getBridgeSingleton(api);
274
+ const meta = getRegisterMeta(api);
275
+ const { bridge, runtime, created } = getBridgeSingleton(api);
276
+ bridge.noteRegister?.({
277
+ source: '~/.openclaw/workspace/plugins/bncr/index.ts',
278
+ pluginVersion: '0.1.1',
279
+ apiRebound: !created,
280
+ apiInstanceId: meta.apiInstanceId,
281
+ registryFingerprint: meta.registryFingerprint,
282
+ });
205
283
  const debugLog = (...args: any[]) => {
206
284
  if (!bridge.isDebugEnabled?.()) return;
207
285
  api.logger.info?.(...args);
208
286
  };
209
287
 
210
- debugLog(`bncr plugin register bridge=${(bridge as any)?.bridgeId || 'unknown'}`);
288
+ debugLog(`bncr plugin register begin bridge=${bridge.getBridgeId?.() || 'unknown'} created=${created}`);
289
+ if (!created) debugLog('bncr bridge api rebound');
211
290
 
212
291
  const resolveDebug = async () => {
213
292
  try {
@@ -218,27 +297,40 @@ const plugin = {
218
297
  }
219
298
  };
220
299
 
221
- api.registerService({
222
- id: 'bncr-bridge-service',
223
- start: async (ctx) => {
224
- const debug = await resolveDebug();
225
- await bridge.startService(ctx, debug);
226
- },
227
- stop: bridge.stopService,
228
- });
300
+ if (!meta.service) {
301
+ api.registerService({
302
+ id: 'bncr-bridge-service',
303
+ start: async (ctx) => {
304
+ const debug = await resolveDebug();
305
+ await bridge.startService(ctx, debug);
306
+ },
307
+ stop: bridge.stopService,
308
+ });
309
+ meta.service = true;
310
+ debugLog('bncr register service ok');
311
+ } else {
312
+ debugLog('bncr register service skip (already registered on this api)');
313
+ }
314
+
315
+ if (!meta.channel) {
316
+ api.registerChannel({ plugin: runtime.createBncrChannelPlugin(bridge) });
317
+ meta.channel = true;
318
+ debugLog('bncr register channel ok');
319
+ } else {
320
+ debugLog('bncr register channel skip (already registered on this api)');
321
+ }
229
322
 
230
- api.registerChannel({ plugin: runtime.createBncrChannelPlugin(bridge) });
231
-
232
- api.registerGatewayMethod('bncr.connect', (opts) => bridge.handleConnect(opts));
233
- api.registerGatewayMethod('bncr.inbound', (opts) => bridge.handleInbound(opts));
234
- api.registerGatewayMethod('bncr.activity', (opts) => bridge.handleActivity(opts));
235
- api.registerGatewayMethod('bncr.ack', (opts) => bridge.handleAck(opts));
236
- api.registerGatewayMethod('bncr.diagnostics', (opts) => bridge.handleDiagnostics(opts));
237
- api.registerGatewayMethod('bncr.file.init', (opts) => bridge.handleFileInit(opts));
238
- api.registerGatewayMethod('bncr.file.chunk', (opts) => bridge.handleFileChunk(opts));
239
- api.registerGatewayMethod('bncr.file.complete', (opts) => bridge.handleFileComplete(opts));
240
- api.registerGatewayMethod('bncr.file.abort', (opts) => bridge.handleFileAbort(opts));
241
- api.registerGatewayMethod('bncr.file.ack', (opts) => bridge.handleFileAck(opts));
323
+ ensureGatewayMethodRegistered(api, 'bncr.connect', (opts) => bridge.handleConnect(opts), debugLog);
324
+ ensureGatewayMethodRegistered(api, 'bncr.inbound', (opts) => bridge.handleInbound(opts), debugLog);
325
+ ensureGatewayMethodRegistered(api, 'bncr.activity', (opts) => bridge.handleActivity(opts), debugLog);
326
+ ensureGatewayMethodRegistered(api, 'bncr.ack', (opts) => bridge.handleAck(opts), debugLog);
327
+ ensureGatewayMethodRegistered(api, 'bncr.diagnostics', (opts) => bridge.handleDiagnostics(opts), debugLog);
328
+ ensureGatewayMethodRegistered(api, 'bncr.file.init', (opts) => bridge.handleFileInit(opts), debugLog);
329
+ ensureGatewayMethodRegistered(api, 'bncr.file.chunk', (opts) => bridge.handleFileChunk(opts), debugLog);
330
+ ensureGatewayMethodRegistered(api, 'bncr.file.complete', (opts) => bridge.handleFileComplete(opts), debugLog);
331
+ ensureGatewayMethodRegistered(api, 'bncr.file.abort', (opts) => bridge.handleFileAbort(opts), debugLog);
332
+ ensureGatewayMethodRegistered(api, 'bncr.file.ack', (opts) => bridge.handleFileAck(opts), debugLog);
333
+ debugLog('bncr plugin register done');
242
334
  },
243
335
  };
244
336
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmoxmo/bncr",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,7 +25,8 @@
25
25
  ],
26
26
  "scripts": {
27
27
  "selfcheck": "node ./scripts/selfcheck.mjs",
28
- "test": "node --import ./tests/register-ts-hooks.mjs --test ./tests/*.test.mjs"
28
+ "test": "node --import ./tests/register-ts-hooks.mjs --test ./tests/*.test.mjs",
29
+ "check-register-drift": "node ./scripts/check-register-drift.mjs"
29
30
  },
30
31
  "peerDependencies": {
31
32
  "openclaw": ">=2026.3.22"
@@ -0,0 +1,105 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ const readNumber = (value, fallback) => {
4
+ const n = Number(value);
5
+ return Number.isFinite(n) ? n : fallback;
6
+ };
7
+
8
+ const args = process.argv.slice(2);
9
+ const options = {
10
+ durationSec: 300,
11
+ intervalSec: 15,
12
+ accountId: 'Primary',
13
+ gatewayBin: 'openclaw',
14
+ };
15
+
16
+ for (let i = 0; i < args.length; i += 1) {
17
+ const arg = args[i];
18
+ if (arg === '--duration-sec') options.durationSec = readNumber(args[++i], options.durationSec);
19
+ else if (arg === '--interval-sec') options.intervalSec = readNumber(args[++i], options.intervalSec);
20
+ else if (arg === '--account-id') options.accountId = args[++i] || options.accountId;
21
+ else if (arg === '--gateway-bin') options.gatewayBin = args[++i] || options.gatewayBin;
22
+ else if (arg === '--help' || arg === '-h') {
23
+ console.log(`Usage: node ./scripts/check-register-drift.mjs [--duration-sec 300] [--interval-sec 15] [--account-id Primary] [--gateway-bin openclaw]\n\nSamples bncr.diagnostics over time and reports whether register counters drift after warmup.`);
24
+ process.exit(0);
25
+ }
26
+ }
27
+
28
+ if (options.durationSec <= 0) throw new Error('durationSec must be > 0');
29
+ if (options.intervalSec <= 0) throw new Error('intervalSec must be > 0');
30
+
31
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
32
+
33
+ const fetchDiagnostics = () => {
34
+ const raw = execFileSync(
35
+ options.gatewayBin,
36
+ ['gateway', 'call', 'bncr.diagnostics', '--json', '--params', JSON.stringify({ accountId: options.accountId })],
37
+ { encoding: 'utf8' },
38
+ );
39
+ const parsed = JSON.parse(raw);
40
+ const reg = parsed?.diagnostics?.register || {};
41
+ const summary = reg?.traceSummary || {};
42
+ return {
43
+ now: parsed?.now ?? Date.now(),
44
+ registerCount: reg?.registerCount ?? null,
45
+ apiGeneration: reg?.apiGeneration ?? null,
46
+ apiInstanceId: reg?.apiInstanceId ?? null,
47
+ registryFingerprint: reg?.registryFingerprint ?? null,
48
+ warmupRegisterCount: summary?.warmupRegisterCount ?? null,
49
+ postWarmupRegisterCount: summary?.postWarmupRegisterCount ?? null,
50
+ unexpectedRegisterAfterWarmup: summary?.unexpectedRegisterAfterWarmup ?? null,
51
+ lastUnexpectedRegisterAt: summary?.lastUnexpectedRegisterAt ?? null,
52
+ sourceBuckets: summary?.sourceBuckets ?? null,
53
+ };
54
+ };
55
+
56
+ const startedAt = Date.now();
57
+ const samples = [];
58
+ const deadline = startedAt + options.durationSec * 1000;
59
+
60
+ while (true) {
61
+ const sample = fetchDiagnostics();
62
+ samples.push(sample);
63
+ const nextAt = Date.now() + options.intervalSec * 1000;
64
+ if (nextAt > deadline) break;
65
+ await sleep(Math.max(0, nextAt - Date.now()));
66
+ }
67
+
68
+ const first = samples[0] || {};
69
+ const last = samples[samples.length - 1] || {};
70
+ const deltaRegisterCount = (last.registerCount ?? 0) - (first.registerCount ?? 0);
71
+ const deltaApiGeneration = (last.apiGeneration ?? 0) - (first.apiGeneration ?? 0);
72
+ const deltaPostWarmupRegisterCount = (last.postWarmupRegisterCount ?? 0) - (first.postWarmupRegisterCount ?? 0);
73
+ const historicalWarmupExternalDrift = Boolean(first.unexpectedRegisterAfterWarmup);
74
+ const newWarmupExternalDriftDuringWindow = deltaPostWarmupRegisterCount > 0;
75
+ const newDriftDuringWindow = deltaRegisterCount > 0 || deltaApiGeneration > 0 || newWarmupExternalDriftDuringWindow;
76
+ const driftDetected = historicalWarmupExternalDrift || newDriftDuringWindow;
77
+
78
+ const result = {
79
+ ok: true,
80
+ accountId: options.accountId,
81
+ durationSec: options.durationSec,
82
+ intervalSec: options.intervalSec,
83
+ startedAt,
84
+ endedAt: Date.now(),
85
+ sampleCount: samples.length,
86
+ first,
87
+ last,
88
+ delta: {
89
+ registerCount: deltaRegisterCount,
90
+ apiGeneration: deltaApiGeneration,
91
+ postWarmupRegisterCount: deltaPostWarmupRegisterCount,
92
+ },
93
+ historicalWarmupExternalDrift,
94
+ newWarmupExternalDriftDuringWindow,
95
+ newDriftDuringWindow,
96
+ driftDetected,
97
+ conclusion: newDriftDuringWindow
98
+ ? 'new register drift was observed during this sampling window'
99
+ : historicalWarmupExternalDrift
100
+ ? 'no new drift during this window, but warmup-external drift had already happened before sampling began'
101
+ : 'register counters stayed stable during this window and no warmup-external drift was flagged',
102
+ samples,
103
+ };
104
+
105
+ console.log(JSON.stringify(result, null, 2));
package/src/channel.ts CHANGED
@@ -63,6 +63,7 @@ const FILE_CHUNK_RETRY = 3;
63
63
  const FILE_ACK_TIMEOUT_MS = 30_000;
64
64
  const FILE_TRANSFER_ACK_TTL_MS = 30_000;
65
65
  const FILE_TRANSFER_KEEP_MS = 6 * 60 * 60 * 1000;
66
+ const REGISTER_WARMUP_WINDOW_MS = 30_000;
66
67
  let BNCR_DEBUG_VERBOSE = false; // 全局调试日志开关(默认关闭)
67
68
 
68
69
  type FileSendTransferState = {
@@ -130,6 +131,18 @@ type PersistedState = {
130
131
  accountId: string;
131
132
  updatedAt: number;
132
133
  }>;
134
+ lastDriftSnapshot?: {
135
+ capturedAt: number;
136
+ registerCount: number | null;
137
+ apiGeneration: number | null;
138
+ postWarmupRegisterCount: number | null;
139
+ apiInstanceId: string | null;
140
+ registryFingerprint: string | null;
141
+ dominantBucket: string | null;
142
+ sourceBuckets: Record<string, number>;
143
+ traceWindowSize: number;
144
+ traceRecent: Array<Record<string, unknown>>;
145
+ } | null;
133
146
  };
134
147
 
135
148
  function now() {
@@ -205,6 +218,56 @@ class BncrBridgeRuntime {
205
218
  private api: OpenClawPluginApi;
206
219
  private statePath: string | null = null;
207
220
  private bridgeId = `${process.pid}-${Math.random().toString(16).slice(2, 8)}`;
221
+ private gatewayPid = process.pid;
222
+ private registerCount = 0;
223
+ private apiGeneration = 0;
224
+ private firstRegisterAt: number | null = null;
225
+ private lastRegisterAt: number | null = null;
226
+ private lastApiRebindAt: number | null = null;
227
+ private pluginSource: string | null = null;
228
+ private pluginVersion: string | null = null;
229
+ private connectionEpoch = 0;
230
+ private primaryLeaseId: string | null = null;
231
+ private acceptedConnections = 0;
232
+ private lastConnectAt: number | null = null;
233
+ private lastDisconnectAt: number | null = null;
234
+ private lastInboundAtGlobal: number | null = null;
235
+ private lastActivityAtGlobal: number | null = null;
236
+ private lastAckAtGlobal: number | null = null;
237
+ private recentConnections = new Map<string, {
238
+ epoch: number;
239
+ connectedAt: number;
240
+ lastActivityAt: number | null;
241
+ isPrimary: boolean;
242
+ }>();
243
+ private staleCounters = {
244
+ staleConnect: 0,
245
+ staleInbound: 0,
246
+ staleActivity: 0,
247
+ staleAck: 0,
248
+ staleFileInit: 0,
249
+ staleFileChunk: 0,
250
+ staleFileComplete: 0,
251
+ staleFileAbort: 0,
252
+ lastStaleAt: null as number | null,
253
+ };
254
+ private lastApiInstanceId: string | null = null;
255
+ private lastRegistryFingerprint: string | null = null;
256
+ private lastDriftSnapshot: PersistedState['lastDriftSnapshot'] = null;
257
+ private registerTraceRecent: Array<{
258
+ ts: number;
259
+ bridgeId: string;
260
+ gatewayPid: number;
261
+ registerCount: number;
262
+ apiGeneration: number;
263
+ apiRebound: boolean;
264
+ apiInstanceId: string | null;
265
+ registryFingerprint: string | null;
266
+ source: string | null;
267
+ pluginVersion: string | null;
268
+ stack: string;
269
+ stackBucket: string;
270
+ }> = [];
208
271
 
209
272
  private connections = new Map<string, BncrConnection>(); // connectionKey -> connection
210
273
  private activeConnectionByAccount = new Map<string, string>(); // accountId -> connectionKey
@@ -246,6 +309,266 @@ class BncrBridgeRuntime {
246
309
  this.api = api;
247
310
  }
248
311
 
312
+ bindApi(api: OpenClawPluginApi) {
313
+ this.api = api;
314
+ }
315
+
316
+ getBridgeId() {
317
+ return this.bridgeId;
318
+ }
319
+
320
+ private classifyRegisterTrace(stack: string) {
321
+ if (stack.includes('prepareSecretsRuntimeSnapshot') || stack.includes('resolveRuntimeWebTools') || stack.includes('resolvePluginWebSearchProviders')) {
322
+ return 'runtime/webtools';
323
+ }
324
+ if (stack.includes('startGatewayServer') || stack.includes('loadGatewayPlugins')) {
325
+ return 'gateway/startup';
326
+ }
327
+ if (stack.includes('resolvePluginImplicitProviders')) {
328
+ return 'provider/discovery/implicit';
329
+ }
330
+ if (stack.includes('resolvePluginDiscoveryProviders')) {
331
+ return 'provider/discovery/discovery';
332
+ }
333
+ if (stack.includes('resolvePluginProviders')) {
334
+ return 'provider/discovery/providers';
335
+ }
336
+ return 'other';
337
+ }
338
+
339
+ private dominantRegisterBucket(sourceBuckets: Record<string, number>) {
340
+ let winner: string | null = null;
341
+ let winnerCount = -1;
342
+ for (const [bucket, count] of Object.entries(sourceBuckets)) {
343
+ if (count > winnerCount) {
344
+ winner = bucket;
345
+ winnerCount = count;
346
+ }
347
+ }
348
+ return winner;
349
+ }
350
+
351
+ private captureDriftSnapshot(summary: ReturnType<BncrBridgeRuntime['buildRegisterTraceSummary']>) {
352
+ this.lastDriftSnapshot = {
353
+ capturedAt: now(),
354
+ registerCount: this.registerCount,
355
+ apiGeneration: this.apiGeneration,
356
+ postWarmupRegisterCount: summary.postWarmupRegisterCount,
357
+ apiInstanceId: this.lastApiInstanceId,
358
+ registryFingerprint: this.lastRegistryFingerprint,
359
+ dominantBucket: summary.dominantBucket,
360
+ sourceBuckets: { ...summary.sourceBuckets },
361
+ traceWindowSize: this.registerTraceRecent.length,
362
+ traceRecent: this.registerTraceRecent.map((trace) => ({ ...trace })),
363
+ };
364
+ this.scheduleSave();
365
+ }
366
+
367
+ private buildRegisterTraceSummary() {
368
+ const buckets: Record<string, number> = {};
369
+ let warmupCount = 0;
370
+ let postWarmupCount = 0;
371
+ let unexpectedRegisterAfterWarmup = false;
372
+ let lastUnexpectedRegisterAt: number | null = null;
373
+ const baseline = this.firstRegisterAt;
374
+
375
+ for (const trace of this.registerTraceRecent) {
376
+ buckets[trace.stackBucket] = (buckets[trace.stackBucket] || 0) + 1;
377
+ const isWarmup = baseline != null && (trace.ts - baseline) <= REGISTER_WARMUP_WINDOW_MS;
378
+ if (isWarmup) {
379
+ warmupCount += 1;
380
+ } else {
381
+ postWarmupCount += 1;
382
+ unexpectedRegisterAfterWarmup = true;
383
+ lastUnexpectedRegisterAt = trace.ts;
384
+ }
385
+ }
386
+
387
+ const dominantBucket = this.dominantRegisterBucket(buckets);
388
+ const likelyRuntimeRegistryDrift = postWarmupCount > 0;
389
+ const likelyStartupFanoutOnly = warmupCount > 0 && postWarmupCount === 0;
390
+
391
+ return {
392
+ startupWindowMs: REGISTER_WARMUP_WINDOW_MS,
393
+ traceWindowSize: this.registerTraceRecent.length,
394
+ sourceBuckets: buckets,
395
+ dominantBucket,
396
+ warmupRegisterCount: warmupCount,
397
+ postWarmupRegisterCount: postWarmupCount,
398
+ unexpectedRegisterAfterWarmup,
399
+ lastUnexpectedRegisterAt,
400
+ likelyRuntimeRegistryDrift,
401
+ likelyStartupFanoutOnly,
402
+ };
403
+ }
404
+
405
+ noteRegister(meta: {
406
+ source?: string;
407
+ pluginVersion?: string;
408
+ apiRebound?: boolean;
409
+ apiInstanceId?: string;
410
+ registryFingerprint?: string;
411
+ }) {
412
+ const ts = now();
413
+ this.registerCount += 1;
414
+ if (this.firstRegisterAt == null) this.firstRegisterAt = ts;
415
+ this.lastRegisterAt = ts;
416
+ if (meta.apiRebound) {
417
+ this.apiGeneration += 1;
418
+ this.lastApiRebindAt = ts;
419
+ } else if (this.registerCount === 1 && this.apiGeneration === 0) {
420
+ this.apiGeneration = 1;
421
+ }
422
+ if (meta.source) this.pluginSource = meta.source;
423
+ if (meta.pluginVersion) this.pluginVersion = meta.pluginVersion;
424
+ if (meta.apiInstanceId) this.lastApiInstanceId = meta.apiInstanceId;
425
+ if (meta.registryFingerprint) this.lastRegistryFingerprint = meta.registryFingerprint;
426
+
427
+ const stack = String(new Error().stack || '')
428
+ .split('\n')
429
+ .slice(2, 7)
430
+ .map((line) => line.trim())
431
+ .filter(Boolean)
432
+ .join(' <- ');
433
+ const stackBucket = this.classifyRegisterTrace(stack);
434
+
435
+ const trace = {
436
+ ts,
437
+ bridgeId: this.bridgeId,
438
+ gatewayPid: this.gatewayPid,
439
+ registerCount: this.registerCount,
440
+ apiGeneration: this.apiGeneration,
441
+ apiRebound: meta.apiRebound === true,
442
+ apiInstanceId: this.lastApiInstanceId,
443
+ registryFingerprint: this.lastRegistryFingerprint,
444
+ source: this.pluginSource,
445
+ pluginVersion: this.pluginVersion,
446
+ stack,
447
+ stackBucket,
448
+ };
449
+ this.registerTraceRecent.push(trace);
450
+ if (this.registerTraceRecent.length > 12) this.registerTraceRecent.splice(0, this.registerTraceRecent.length - 12);
451
+
452
+ const summary = this.buildRegisterTraceSummary();
453
+ if (summary.postWarmupRegisterCount > 0) this.captureDriftSnapshot(summary);
454
+
455
+ this.api.logger.info?.(
456
+ `[bncr-register-trace] ${JSON.stringify(trace)}`,
457
+ );
458
+ }
459
+
460
+ private createLeaseId() {
461
+ return typeof crypto?.randomUUID === 'function'
462
+ ? `lease_${crypto.randomUUID()}`
463
+ : `lease_${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`;
464
+ }
465
+
466
+ private acceptConnection() {
467
+ const ts = now();
468
+ const leaseId = this.createLeaseId();
469
+ const connectionEpoch = ++this.connectionEpoch;
470
+ this.primaryLeaseId = leaseId;
471
+ this.acceptedConnections += 1;
472
+ this.lastConnectAt = ts;
473
+ this.recentConnections.set(leaseId, {
474
+ epoch: connectionEpoch,
475
+ connectedAt: ts,
476
+ lastActivityAt: null,
477
+ isPrimary: true,
478
+ });
479
+ for (const [id, entry] of this.recentConnections.entries()) {
480
+ if (id !== leaseId) entry.isPrimary = false;
481
+ }
482
+ while (this.recentConnections.size > 8) {
483
+ const oldest = this.recentConnections.keys().next().value;
484
+ if (!oldest) break;
485
+ this.recentConnections.delete(oldest);
486
+ }
487
+ return { leaseId, connectionEpoch, acceptedAt: ts };
488
+ }
489
+
490
+ private observeLease(
491
+ kind: 'connect' | 'inbound' | 'activity' | 'ack' | 'file.init' | 'file.chunk' | 'file.complete' | 'file.abort',
492
+ params: { leaseId?: string; connectionEpoch?: number },
493
+ ) {
494
+ const leaseId = typeof params.leaseId === 'string' ? params.leaseId.trim() : '';
495
+ const connectionEpoch = typeof params.connectionEpoch === 'number' ? params.connectionEpoch : undefined;
496
+ if (!leaseId && connectionEpoch == null) return { stale: false, reason: 'missing' as const };
497
+ const staleByLease = !!leaseId && this.primaryLeaseId != null && leaseId !== this.primaryLeaseId;
498
+ const staleByEpoch = connectionEpoch != null && this.connectionEpoch > 0 && connectionEpoch !== this.connectionEpoch;
499
+ const stale = staleByLease || staleByEpoch;
500
+ if (!stale) return { stale: false, reason: 'ok' as const };
501
+ this.staleCounters.lastStaleAt = now();
502
+ switch (kind) {
503
+ case 'connect': this.staleCounters.staleConnect += 1; break;
504
+ case 'inbound': this.staleCounters.staleInbound += 1; break;
505
+ case 'activity': this.staleCounters.staleActivity += 1; break;
506
+ case 'ack': this.staleCounters.staleAck += 1; break;
507
+ case 'file.init': this.staleCounters.staleFileInit += 1; break;
508
+ case 'file.chunk': this.staleCounters.staleFileChunk += 1; break;
509
+ case 'file.complete': this.staleCounters.staleFileComplete += 1; break;
510
+ case 'file.abort': this.staleCounters.staleFileAbort += 1; break;
511
+ }
512
+ this.api.logger.warn?.(
513
+ `[bncr] stale ${kind} observed lease=${leaseId || '-'} epoch=${connectionEpoch ?? '-'} currentLease=${this.primaryLeaseId || '-'} currentEpoch=${this.connectionEpoch}`,
514
+ );
515
+ return { stale: true, reason: 'mismatch' as const };
516
+ }
517
+
518
+ private buildExtendedDiagnostics(accountId: string) {
519
+ const diagnostics = this.buildIntegratedDiagnostics(accountId) as Record<string, any>;
520
+ return {
521
+ ...diagnostics,
522
+ register: {
523
+ bridgeId: this.bridgeId,
524
+ gatewayPid: this.gatewayPid,
525
+ pluginVersion: this.pluginVersion,
526
+ source: this.pluginSource,
527
+ apiInstanceId: this.lastApiInstanceId,
528
+ registryFingerprint: this.lastRegistryFingerprint,
529
+ registerCount: this.registerCount,
530
+ firstRegisterAt: this.firstRegisterAt,
531
+ lastRegisterAt: this.lastRegisterAt,
532
+ lastApiRebindAt: this.lastApiRebindAt,
533
+ apiGeneration: this.apiGeneration,
534
+ traceRecent: this.registerTraceRecent.slice(),
535
+ traceSummary: this.buildRegisterTraceSummary(),
536
+ lastDriftSnapshot: this.lastDriftSnapshot,
537
+ },
538
+ connection: {
539
+ active: this.activeConnectionCount(accountId),
540
+ primaryLeaseId: this.primaryLeaseId,
541
+ primaryEpoch: this.connectionEpoch || null,
542
+ acceptedConnections: this.acceptedConnections,
543
+ lastConnectAt: this.lastConnectAt,
544
+ lastDisconnectAt: this.lastDisconnectAt,
545
+ lastActivityAt: this.lastActivityAtGlobal,
546
+ lastInboundAt: this.lastInboundAtGlobal,
547
+ lastAckAt: this.lastAckAtGlobal,
548
+ recent: Array.from(this.recentConnections.entries()).map(([leaseId, entry]) => ({
549
+ leaseId,
550
+ epoch: entry.epoch,
551
+ connectedAt: entry.connectedAt,
552
+ lastActivityAt: entry.lastActivityAt,
553
+ isPrimary: entry.isPrimary,
554
+ })),
555
+ },
556
+ protocol: {
557
+ bridgeVersion: BRIDGE_VERSION,
558
+ protocolVersion: 2,
559
+ minClientProtocol: 1,
560
+ features: {
561
+ leaseId: true,
562
+ connectionEpoch: true,
563
+ staleObserveOnly: true,
564
+ staleRejectAck: false,
565
+ staleRejectFile: false,
566
+ },
567
+ },
568
+ stale: { ...this.staleCounters },
569
+ };
570
+ }
571
+
249
572
  isDebugEnabled(): boolean {
250
573
  try {
251
574
  const cfg = (this.api.runtime.config?.get?.() as any) || {};
@@ -500,6 +823,25 @@ class BncrBridgeRuntime {
500
823
  this.lastOutboundByAccount.set(accountId, updatedAt);
501
824
  }
502
825
 
826
+ this.lastDriftSnapshot = data.lastDriftSnapshot && typeof data.lastDriftSnapshot === 'object'
827
+ ? {
828
+ capturedAt: Number((data.lastDriftSnapshot as any).capturedAt || 0),
829
+ registerCount: Number.isFinite(Number((data.lastDriftSnapshot as any).registerCount)) ? Number((data.lastDriftSnapshot as any).registerCount) : null,
830
+ apiGeneration: Number.isFinite(Number((data.lastDriftSnapshot as any).apiGeneration)) ? Number((data.lastDriftSnapshot as any).apiGeneration) : null,
831
+ postWarmupRegisterCount: Number.isFinite(Number((data.lastDriftSnapshot as any).postWarmupRegisterCount)) ? Number((data.lastDriftSnapshot as any).postWarmupRegisterCount) : null,
832
+ apiInstanceId: asString((data.lastDriftSnapshot as any).apiInstanceId || '').trim() || null,
833
+ registryFingerprint: asString((data.lastDriftSnapshot as any).registryFingerprint || '').trim() || null,
834
+ dominantBucket: asString((data.lastDriftSnapshot as any).dominantBucket || '').trim() || null,
835
+ sourceBuckets: ((data.lastDriftSnapshot as any).sourceBuckets && typeof (data.lastDriftSnapshot as any).sourceBuckets === 'object')
836
+ ? { ...((data.lastDriftSnapshot as any).sourceBuckets as Record<string, number>) }
837
+ : {},
838
+ traceWindowSize: Number((data.lastDriftSnapshot as any).traceWindowSize || 0),
839
+ traceRecent: Array.isArray((data.lastDriftSnapshot as any).traceRecent)
840
+ ? [ ...((data.lastDriftSnapshot as any).traceRecent as Array<Record<string, unknown>>) ]
841
+ : [],
842
+ }
843
+ : null;
844
+
503
845
  // 兼容旧状态文件:若尚未持久化 lastSession*/lastActivity*,从 sessionRoutes 回填。
504
846
  if (this.lastSessionByAccount.size === 0 && this.sessionRoutes.size > 0) {
505
847
  for (const [sessionKey, info] of this.sessionRoutes.entries()) {
@@ -560,6 +902,20 @@ class BncrBridgeRuntime {
560
902
  accountId,
561
903
  updatedAt,
562
904
  })),
905
+ lastDriftSnapshot: this.lastDriftSnapshot
906
+ ? {
907
+ capturedAt: this.lastDriftSnapshot.capturedAt,
908
+ registerCount: this.lastDriftSnapshot.registerCount,
909
+ apiGeneration: this.lastDriftSnapshot.apiGeneration,
910
+ postWarmupRegisterCount: this.lastDriftSnapshot.postWarmupRegisterCount,
911
+ apiInstanceId: this.lastDriftSnapshot.apiInstanceId,
912
+ registryFingerprint: this.lastDriftSnapshot.registryFingerprint,
913
+ dominantBucket: this.lastDriftSnapshot.dominantBucket,
914
+ sourceBuckets: { ...this.lastDriftSnapshot.sourceBuckets },
915
+ traceWindowSize: this.lastDriftSnapshot.traceWindowSize,
916
+ traceRecent: this.lastDriftSnapshot.traceRecent.map((trace) => ({ ...trace })),
917
+ }
918
+ : null,
563
919
  };
564
920
 
565
921
  await writeJsonFileAtomically(this.statePath, data);
@@ -1718,6 +2074,7 @@ class BncrBridgeRuntime {
1718
2074
  this.markSeen(accountId, connId, clientId);
1719
2075
  this.markActivity(accountId);
1720
2076
  this.incrementCounter(this.connectEventsByAccount, accountId);
2077
+ const lease = this.acceptConnection();
1721
2078
 
1722
2079
  respond(true, {
1723
2080
  channel: CHANNEL_ID,
@@ -1729,7 +2086,13 @@ class BncrBridgeRuntime {
1729
2086
  activeConnections: this.activeConnectionCount(accountId),
1730
2087
  pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
1731
2088
  deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
1732
- diagnostics: this.buildIntegratedDiagnostics(accountId),
2089
+ diagnostics: this.buildExtendedDiagnostics(accountId),
2090
+ leaseId: lease.leaseId,
2091
+ connectionEpoch: lease.connectionEpoch,
2092
+ protocolVersion: 2,
2093
+ acceptedAt: lease.acceptedAt,
2094
+ serverPid: this.gatewayPid,
2095
+ bridgeId: this.bridgeId,
1733
2096
  now: now(),
1734
2097
  });
1735
2098
 
@@ -1743,6 +2106,8 @@ class BncrBridgeRuntime {
1743
2106
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1744
2107
  this.rememberGatewayContext(context);
1745
2108
  this.markSeen(accountId, connId, clientId);
2109
+ this.observeLease('ack', params ?? {});
2110
+ this.lastAckAtGlobal = now();
1746
2111
  this.incrementCounter(this.ackEventsByAccount, accountId);
1747
2112
 
1748
2113
  const messageId = asString(params?.messageId || '').trim();
@@ -1802,6 +2167,8 @@ class BncrBridgeRuntime {
1802
2167
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
1803
2168
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1804
2169
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2170
+ this.observeLease('activity', params ?? {});
2171
+ this.lastActivityAtGlobal = now();
1805
2172
  if (BNCR_DEBUG_VERBOSE) {
1806
2173
  this.api.logger.info?.(
1807
2174
  `[bncr-activity] ${JSON.stringify({
@@ -1834,7 +2201,7 @@ class BncrBridgeRuntime {
1834
2201
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
1835
2202
  const cfg = await this.api.runtime.config.loadConfig();
1836
2203
  const runtime = this.getAccountRuntimeSnapshot(accountId);
1837
- const diagnostics = this.buildIntegratedDiagnostics(accountId);
2204
+ const diagnostics = this.buildExtendedDiagnostics(accountId);
1838
2205
  const permissions = buildBncrPermissionSummary(cfg ?? {});
1839
2206
  const probe = probeBncrAccount({
1840
2207
  accountId,
@@ -1869,6 +2236,7 @@ class BncrBridgeRuntime {
1869
2236
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1870
2237
  this.rememberGatewayContext(context);
1871
2238
  this.markSeen(accountId, connId, clientId);
2239
+ this.observeLease('file.init', params ?? {});
1872
2240
  this.markActivity(accountId);
1873
2241
 
1874
2242
  const transferId = asString(params?.transferId || '').trim();
@@ -1938,6 +2306,7 @@ class BncrBridgeRuntime {
1938
2306
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1939
2307
  this.rememberGatewayContext(context);
1940
2308
  this.markSeen(accountId, connId, clientId);
2309
+ this.observeLease('file.chunk', params ?? {});
1941
2310
  this.markActivity(accountId);
1942
2311
 
1943
2312
  const transferId = asString(params?.transferId || '').trim();
@@ -1991,6 +2360,7 @@ class BncrBridgeRuntime {
1991
2360
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
1992
2361
  this.rememberGatewayContext(context);
1993
2362
  this.markSeen(accountId, connId, clientId);
2363
+ this.observeLease('file.complete', params ?? {});
1994
2364
  this.markActivity(accountId);
1995
2365
 
1996
2366
  const transferId = asString(params?.transferId || '').trim();
@@ -2054,6 +2424,7 @@ class BncrBridgeRuntime {
2054
2424
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2055
2425
  this.rememberGatewayContext(context);
2056
2426
  this.markSeen(accountId, connId, clientId);
2427
+ this.observeLease('file.abort', params ?? {});
2057
2428
  this.markActivity(accountId);
2058
2429
 
2059
2430
  const transferId = asString(params?.transferId || '').trim();
@@ -2149,7 +2520,9 @@ class BncrBridgeRuntime {
2149
2520
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2150
2521
  this.rememberGatewayContext(context);
2151
2522
  this.markSeen(accountId, connId, clientId);
2523
+ this.observeLease('inbound', params ?? {});
2152
2524
  this.markActivity(accountId);
2525
+ this.lastInboundAtGlobal = now();
2153
2526
  this.incrementCounter(this.inboundEventsByAccount, accountId);
2154
2527
 
2155
2528
  if (!platform || (!userId && !groupId)) {