@coclaw/openclaw-coclaw 0.21.0 → 0.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.21.0",
3
+ "version": "0.21.2",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat",
@@ -23,7 +23,7 @@ const RECONNECT_MS = 10_000;
23
23
  const CONNECT_TIMEOUT_MS = 10_000;
24
24
  const SERVER_HB_PING_MS = 25_000;
25
25
  const SERVER_HB_TIMEOUT_MS = 45_000;
26
- const SERVER_HB_MAX_MISS = 4; // 连续 4 次无响应才断连(~3 分钟)
26
+ const SERVER_HB_MAX_MISS = 3; // 连续 3 次无响应才断连(~135s)。上游主线程 spike 实测最坏 ~89.5s(issue #75069),余量 ~1.5x
27
27
  // gateway 握手失败的指数退避表:每个元素是"上一次失败"之后、"下一次尝试"之前的等待时间。
28
28
  // 最多 5 次重试(加上首次尝试共 6 次),全部失败后进入 gave-up 终态,不再自动尝试。
29
29
  const GATEWAY_RETRY_DELAYS_MS = [5_000, 10_000, 20_000, 20_000, 20_000];
@@ -265,16 +265,11 @@ export class RealtimeBridge {
265
265
  }
266
266
 
267
267
  __closeGatewayWs() {
268
- // server WS 失效主动关闭 gateway 时,取消任何 pending 重试定时器、把连续失败计数归零:
269
- // server 会话应从新预算开始重试 gateway,避免旧会话的零散失败累计吞掉未来的重试机会。
270
- // 不清 __gatewayGaveUp / __gatewayLegacyMode —— 那是跨会话的终态/学习,只由 stop() 复位。
271
- if (this.__gatewayRetryTimer) {
272
- clearTimeout(this.__gatewayRetryTimer);
273
- this.__gatewayRetryTimer = null;
274
- }
275
- this.__gatewayAttempts = 0;
276
- // 主动关闭时立即清 lag probe,不依赖 close 事件回调时序,避免 close 事件延迟期间 probe 误报
277
- this.__clearAllLagProbes();
268
+ // 仅由显式销毁路径(stop / refresh)调用:职责单一——关闭当前 gateway WS 实例 +
269
+ // 结算未完成的 gateway RPC。retry timer / attempts 由 stop() 复位;lag probes 由 stop()
270
+ // 显式 __clearAllLagProbes 兜底(ws close 事件异步触发,期间 gatewayWs 已被这里置 null,
271
+ // stale guard 早返不再清理);P2P 路由表(__dcPendingRequests / __runEventRoutes)
272
+ // 已不再随内线翻转清理,stop() 会显式 clear / destroy。
278
273
  if (!this.gatewayWs) {
279
274
  return;
280
275
  }
@@ -292,10 +287,6 @@ export class RealtimeBridge {
292
287
  settle({ ok: false, error: 'gateway_closed' });
293
288
  }
294
289
  this.gatewayPendingRequests.clear();
295
- // 清空 UI 转发 RPC 路由表:gateway 已断,不会再有响应回来;不主动通知 UI,由 UI 30/60s 超时兜底
296
- this.__dcPendingRequests.clear();
297
- // 同步清空 runId 路由表(gateway 已断,不会再有 event:agent 推过来)
298
- this.__runEventRoutes?.clear();
299
290
  }
300
291
 
301
292
  /** 懒加载 WebRtcPeer(promise 锁防并发重复创建) */
@@ -338,16 +329,21 @@ export class RealtimeBridge {
338
329
  }
339
330
  /* c8 ignore stop */
340
331
 
341
- /* c8 ignore next 7 -- 防御性检查,serverWs 通常在调用时可用 */
342
332
  __forwardToServer(payload) {
343
333
  if (!this.serverWs || this.serverWs.readyState !== 1) {
334
+ // WS 断窗口期 onSend 调用:webrtcPeer 异步回调(trickle ICE candidate、SDP 应答等)
335
+ // 本身没有 queue/rollback 机制,丢失对 plugin 端 PC 状态机不致命——UI 端 sig.state
336
+ // 恢复后会主动触发新一轮 ICE restart,重发完整 candidate set 与 SDP。仅记本地 log
337
+ // 便于事后排查;TODO 跟进完整方案(queue / rollback connId)。
338
+ this.logger.warn?.(`[coclaw] forward dropped: ws not ready (type=${payload?.type ?? 'unknown'})`);
344
339
  return;
345
340
  }
346
341
  try {
347
342
  this.serverWs.send(JSON.stringify(payload));
348
343
  }
349
- /* c8 ignore next */
350
- catch {}
344
+ catch (e) {
345
+ this.logger.warn?.(`[coclaw] forward send failed (type=${payload?.type ?? 'unknown'}): ${e?.message ?? e}`);
346
+ }
351
347
  }
352
348
 
353
349
  __nextGatewayReqId(prefix = 'coclaw-rpc') {
@@ -655,7 +651,7 @@ export class RealtimeBridge {
655
651
  // 用 rpcSeq 保证 ID 唯一,避免 v3→legacy 同毫秒内两次调用产生相同 id
656
652
  this.gatewayRpcSeq += 1;
657
653
  this.gatewayConnectReqId = `coclaw-connect-${Date.now()}-${this.gatewayRpcSeq}`;
658
- this.__logDebug(`gateway connect request -> id=${this.gatewayConnectReqId} legacy=${legacy}`);
654
+ this.logger.info?.(`[coclaw] gateway connect request -> id=${this.gatewayConnectReqId} legacy=${legacy}`);
659
655
  try {
660
656
  const authToken = this.__resolveGatewayAuthToken();
661
657
  const params = {
@@ -726,11 +722,13 @@ export class RealtimeBridge {
726
722
  if (this.__gatewayGaveUp || this.__gatewayRetryTimer) {
727
723
  return;
728
724
  }
729
- if (this.gatewayWs || !this.serverWs || this.serverWs.readyState !== 1) {
725
+ // 外/内/P2P 三条线各自独立:内线(plugin↔本机 gateway)的建连节奏不再看外线脸色。
726
+ // 外线断不影响这里启动,外线即便没起来也允许内线先跑(DC RPC 仍能通过 P2P 直达)。
727
+ if (this.gatewayWs) {
730
728
  return;
731
729
  }
732
730
  const WebSocketCtor = this.__resolveWebSocket();
733
- /* c8 ignore next 3 -- 已在 __connectIfNeeded 中守卫 */
731
+ /* c8 ignore next 3 -- WebSocketCtor=null 仅测试注入下出现,生产路径 ws 库总能解析 */
734
732
  if (!WebSocketCtor) {
735
733
  return;
736
734
  }
@@ -770,7 +768,7 @@ export class RealtimeBridge {
770
768
  if (payload.type === 'event' && payload.event === 'connect.challenge') {
771
769
  const nonce = payload?.payload?.nonce ?? '';
772
770
  lastChallengeNonce = nonce;
773
- this.__logDebug(`gateway event <- connect.challenge legacyMode=${this.__gatewayLegacyMode}`);
771
+ this.logger.info?.(`[coclaw] gateway event <- connect.challenge legacyMode=${this.__gatewayLegacyMode}`);
774
772
  // 已经学到此 gateway 是 legacy(上一条 WS 回退过)→ 直接发 legacy 握手
775
773
  if (this.__gatewayLegacyMode) {
776
774
  pendingLegacyAttempted = true;
@@ -787,7 +785,7 @@ export class RealtimeBridge {
787
785
  wasReady = true;
788
786
  this.__gatewayAttempts = 0; // 成功握手 → 重置失败计数,让后续瞬态断开有完整重试预算
789
787
  remoteLog('ws.connected peer=gateway');
790
- this.__logDebug(`gateway connect ok <- id=${payload.id}`);
788
+ this.logger.info?.(`[coclaw] gateway connect ok <- id=${payload.id}`);
791
789
  this.gatewayConnectReqId = null;
792
790
  this.__ensureSessionsPromise = this.__ensureAllAgentSessions();
793
791
  this.__pushInstanceInfo();
@@ -918,7 +916,7 @@ export class RealtimeBridge {
918
916
  });
919
917
 
920
918
  ws.addEventListener('open', () => {
921
- this.__logDebug('gateway ws open, waiting for connect.challenge');
919
+ this.logger.info?.('[coclaw] gateway ws open, waiting for connect.challenge');
922
920
  });
923
921
  ws.addEventListener('close', (ev) => {
924
922
  // 区分本端 plugin 主动关闭与对端 OpenClaw gateway 关闭:日志/远程上报用不同事件名,
@@ -958,14 +956,11 @@ export class RealtimeBridge {
958
956
  settle({ ok: false, error: 'gateway_closed' });
959
957
  }
960
958
  this.gatewayPendingRequests.clear();
961
- // 同步清空 UI 转发 RPC 路由表(同 __closeGatewayWs 语义)
962
- this.__dcPendingRequests.clear();
963
- // 同步清空 runId 路由表(gateway 已断,不会再有 event:agent 推过来)
964
- this.__runEventRoutes?.clear();
965
- // 调度下一次尝试:仅在 bridge 仍活着、未 gave-up、server WS 健康时;
966
- // 其他场景(如 bridge stop、server WS 已断)由上游流程兜底,不参与 gateway 重试。
959
+ // P2P 路由表(__dcPendingRequests / __runEventRoutes)不在这里清:
960
+ // 内线翻转不应级联影响 P2P。已发出去的 RPC 在 UI 侧 30/60s 超时兜底;
961
+ // 路由表条目由 start() 启动的周期扫描器(24h TTL)回收,stop() 时显式 clear。
962
+ // 调度下一次尝试:仅在 bridge 仍活着、未 gave-up 时;不再看外线脸色(外/内独立重试)。
967
963
  if (this.started && !this.__gatewayGaveUp
968
- && this.serverWs && this.serverWs.readyState === 1
969
964
  && (wasReady || connectFailReported)) {
970
965
  if (wasReady) {
971
966
  // 之前握成功过,视为瞬态掉线 → 重置计数,让新一轮拿到完整重试预算
@@ -1241,7 +1236,7 @@ export class RealtimeBridge {
1241
1236
  this.logger.warn?.(`[coclaw] realtime bridge connect timeout, will retry: ${maskedTarget}`);
1242
1237
  remoteLog('ws.connect-timeout peer=server');
1243
1238
  this.serverWs = null;
1244
- this.__closeGatewayWs();
1239
+ // 外线超时不级联关内线/清 P2P 路由:三条线各自独立。
1245
1240
  this.__scheduleReconnect();
1246
1241
  try { sock.close(4000, 'connect_timeout'); }
1247
1242
  /* c8 ignore next */
@@ -1267,7 +1262,12 @@ export class RealtimeBridge {
1267
1262
  // __buildEnvLine 内部所有读取均为缓存值,无 native syscall。
1268
1263
  remoteLog(this.__buildEnvLine());
1269
1264
  this.__startServerHeartbeat(sock);
1270
- this.__ensureGatewayConnection();
1265
+ // 三线独立后内线可能先于外线就绪:那次 push 因外线未 open 在 server 路径被 drop,
1266
+ // 故外线 open 时若内线已 ready 补推一次。门控 gatewayReady 是因为 agentModels 依赖
1267
+ // 内线 agents.list RPC,内线没就绪发出去字段不全会污染 admin 仪表盘。
1268
+ if (this.gatewayReady) {
1269
+ this.__pushInstanceInfo();
1270
+ }
1271
1271
  });
1272
1272
 
1273
1273
  sock.addEventListener('message', async (event) => {
@@ -1321,20 +1321,26 @@ export class RealtimeBridge {
1321
1321
  const wasIntentional = this.intentionallyClosed;
1322
1322
  this.serverWs = null;
1323
1323
  this.intentionallyClosed = false;
1324
- this.__closeGatewayWs();
1325
- if (this.webrtcPeer) {
1326
- try { await this.webrtcPeer.closeAll(); }
1327
- /* c8 ignore next 3 -- 防御性兜底,werift close 异常时不可崩溃 gateway */
1328
- catch (e) { this.logger.warn?.(`[coclaw/rtc] closeAll failed: ${e?.message}`); }
1329
- this.webrtcPeer = null;
1330
- this.__webrtcPeerReady = null;
1331
- }
1332
- if (this.__fileHandler) {
1333
- this.__fileHandler.cancelCleanup();
1334
- this.__fileHandler = null;
1335
- }
1336
-
1337
- if (event?.code === 4001 || event?.code === 4003) {
1324
+ // 外线 close 不再级联关内线/清 P2P 路由:内线(plugin↔本机 gateway)与 P2P
1325
+ // 与外线(plugin↔远端 server)解耦,各自独立维护生命周期。auth-close 分支
1326
+ // (下方)是唯一允许级联 PC/fileHandler/token 的路径,因为 plugin 已失资格。
1327
+
1328
+ // auth-close (4001/4003) 语义是 plugin 失去服务资格,必须连同 PC/fileHandler 一起清;
1329
+ // 其它 close code(含 4000 心跳超时、1006 abnormal、1011 等)视为信令通道瞬态不通,
1330
+ // 保留 webrtcPeer / fileHandler 实例供重连后复用,避免雪崩式 PC 重建。
1331
+ const isAuthClose = event?.code === 4001 || event?.code === 4003;
1332
+ if (isAuthClose) {
1333
+ if (this.webrtcPeer) {
1334
+ try { await this.webrtcPeer.closeAll(); }
1335
+ /* c8 ignore next 3 -- 防御性兜底,werift close 异常时不可崩溃 gateway */
1336
+ catch (e) { this.logger.warn?.(`[coclaw/rtc] closeAll failed: ${e?.message}`); }
1337
+ this.webrtcPeer = null;
1338
+ this.__webrtcPeerReady = null;
1339
+ }
1340
+ if (this.__fileHandler) {
1341
+ this.__fileHandler.cancelCleanup();
1342
+ this.__fileHandler = null;
1343
+ }
1338
1344
  remoteLog(`ws.auth-close peer=server code=${event.code}`);
1339
1345
  this.logger.warn?.(`[coclaw] server ws auth-close (code=${event.code}), clearing local token`);
1340
1346
  try {
@@ -1348,8 +1354,9 @@ export class RealtimeBridge {
1348
1354
  }
1349
1355
 
1350
1356
  if (!wasIntentional) {
1351
- remoteLog(`ws.disconnected peer=server code=${event?.code ?? 'unknown'} reason=${event?.reason ?? 'n/a'}`);
1352
- this.logger.warn?.(`[coclaw] realtime bridge closed (${event?.code ?? 'unknown'}: ${event?.reason ?? 'n/a'}), will retry in ${RECONNECT_MS}ms`);
1357
+ const sessionCount = this.webrtcPeer?.__sessions?.size ?? 0;
1358
+ remoteLog(`ws.disconnected peer=server code=${event?.code ?? 'unknown'} reason=${event?.reason ?? 'n/a'} keep-pc=true sessions=${sessionCount}`);
1359
+ this.logger.warn?.(`[coclaw] realtime bridge closed (${event?.code ?? 'unknown'}: ${event?.reason ?? 'n/a'}), will retry in ${RECONNECT_MS}ms (keep-pc, sessions=${sessionCount})`);
1353
1360
  this.__scheduleReconnect();
1354
1361
  }
1355
1362
  });
@@ -1365,7 +1372,7 @@ export class RealtimeBridge {
1365
1372
  /* c8 ignore next -- ?./?? fallback */
1366
1373
  this.logger.warn?.(`[coclaw] realtime bridge error, will retry in ${RECONNECT_MS}ms: ${String(err?.message ?? err)}`);
1367
1374
  this.serverWs = null;
1368
- this.__closeGatewayWs();
1375
+ // 外线 error 不级联关内线/清 P2P 路由:三条线各自独立。
1369
1376
  this.__scheduleReconnect();
1370
1377
  try { sock.close(4000, 'connect_error'); }
1371
1378
  /* c8 ignore next */
@@ -1502,7 +1509,11 @@ export class RealtimeBridge {
1502
1509
  scanMs: this.__runEventRoutesScanMs,
1503
1510
  });
1504
1511
  this.__runEventRoutes.init();
1512
+ // 外线(plugin↔远端 server)先发起 connectIfNeeded:仅创建 WebSocket 即返回,不阻塞内线。
1505
1513
  await this.__connectIfNeeded();
1514
+ // 三条线各自独立启动:内线(plugin↔本机 gateway)由 start() 主动触发,
1515
+ // 不再依附于外线 open。即便外线建连失败/未配置 token,内线仍能起来支撑 DC RPC。
1516
+ this.__ensureGatewayConnection();
1506
1517
  }
1507
1518
 
1508
1519
  /**
@@ -1533,6 +1544,8 @@ export class RealtimeBridge {
1533
1544
  this.__clearServerHeartbeat();
1534
1545
  this.__clearConnectTimer();
1535
1546
  // stop() / refresh() 兜底回收 lag 探针的 timer,防 unref 仍残留。
1547
+ // 顺序依赖:必须早于下方 __closeGatewayWs;后者会立刻把 gatewayWs 置 null,
1548
+ // 异步触发的 close 事件会被 stale guard 早返而无法走到 __clearAllLagProbes。
1536
1549
  this.__clearAllLagProbes();
1537
1550
  if (this.reconnectTimer) {
1538
1551
  clearTimeout(this.reconnectTimer);
@@ -1557,6 +1570,9 @@ export class RealtimeBridge {
1557
1570
  this.__runEventRoutes.destroy();
1558
1571
  this.__runEventRoutes = null;
1559
1572
  }
1573
+ // stop / refresh 是显式销毁路径:P2P 路由表不再随内线翻转清理(动作 5),
1574
+ // 但显式销毁时必须清干净,避免 refresh 后留下指向旧 connId 的孤儿条目。
1575
+ this.__dcPendingRequests.clear();
1560
1576
  this.__closeGatewayWs();
1561
1577
  if (this.webrtcPeer) {
1562
1578
  await this.webrtcPeer.closeAll().catch(() => {});
@@ -14,7 +14,8 @@
14
14
  * - timer 必须 unref()——避免 hold 进程退出
15
15
  *
16
16
  * 与 reqId 路由表(realtime-bridge.js __dcPendingRequests)保持行为对齐:
17
- * 对 PC 关闭不做联动清理,TTL 兜底;网关 WS 断开走 clear()。
17
+ * 对 PC 关闭和网关 WS 翻转都不做联动清理(外/内/P2P 三线独立),TTL 兜底;
18
+ * 仅显式销毁路径(bridge.stop / refresh)会通过 destroy() 清表。
18
19
  */
19
20
 
20
21
  /** 路由条目最大存活时间(24h),与 reqId 表对齐 */