@eyeclaw/eyeclaw 2.4.0 → 2.4.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": "@eyeclaw/eyeclaw",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "EyeClaw plugin for OpenClaw - HTTP SSE streaming + WebSocket client",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -29,8 +29,16 @@ export class EyeClawWebSocketClient {
29
29
  private reconnecting = false
30
30
  private subscribed = false
31
31
  private pingInterval: any = null
32
+ private connectionHealthCheckTimer: any = null
32
33
  private chunkSequence = 0 // 每个会话的 chunk 序号
33
34
  private accumulatedContent = '' // 累积完整内容用于兜底
35
+ private lastConnectedAt = 0 // 上次连接成功时间戳
36
+ private deploymentDetected = false // 是否检测到部署重启
37
+ private wasConnected = false // 之前是否连接成功过
38
+ private reconnectTimer: any = null // 重连定时器引用
39
+ private serverVersion = '' // 服务器版本(用于检测部署)
40
+ private consecutiveFailures = 0 // 连续失败次数
41
+ private lastCloseCode = 0 // 上次关闭码
34
42
 
35
43
  // 🔥 ACK 机制:追踪已发送和已确认的 chunks
36
44
  private sentChunks = 0 // 已发送的 chunks 数量
@@ -63,6 +71,17 @@ export class EyeClawWebSocketClient {
63
71
  this.ws.onopen = () => {
64
72
  this.api.logger.info('[EyeClaw] WebSocket connected')
65
73
  this.reconnectAttempts = 0
74
+ this.consecutiveFailures = 0 // 重置连续失败计数
75
+ this.lastConnectedAt = Date.now()
76
+ this.wasConnected = true
77
+
78
+ // 如果检测到部署重启(之前已连接过),立即触发一次快速心跳
79
+ if (this.deploymentDetected) {
80
+ this.api.logger.info('[EyeClaw] 🚀 Deployment recovery detected, sending immediate ping')
81
+ this.deploymentDetected = false
82
+ // 延迟 500ms 确保订阅完成后再发心跳
83
+ setTimeout(() => this.sendPing(), 500)
84
+ }
66
85
  }
67
86
 
68
87
  this.ws.onmessage = (event) => {
@@ -74,9 +93,35 @@ export class EyeClawWebSocketClient {
74
93
  }
75
94
 
76
95
  this.ws.onclose = () => {
77
- this.api.logger.warn('[EyeClaw] WebSocket disconnected')
96
+ const closeCode = this.ws?.closeCode || 0
97
+ const wasClean = closeCode === 1000 || closeCode === 1001
98
+
99
+ this.api.logger.warn(`[EyeClaw] WebSocket closed (code: ${closeCode}, clean: ${wasClean})`)
100
+
78
101
  this.subscribed = false
79
102
  this.stopPing()
103
+ this.lastCloseCode = closeCode
104
+
105
+ // 统计连续失败
106
+ this.consecutiveFailures++
107
+
108
+ // 检测是否是部署导致的断连(之前已成功连接过,且断开时间 > 5 秒)
109
+ // 或者关闭码为 1000/1001(正常关闭)但之前已连接
110
+ if (this.wasConnected && Date.now() - this.lastConnectedAt > 5000) {
111
+ // 正常关闭或部署重启
112
+ if (wasClean) {
113
+ this.deploymentDetected = true
114
+ this.api.logger.info('[EyeClaw] 🔄 Deployment or clean close detected, will reconnect immediately')
115
+ } else {
116
+ // 非正常关闭但之前已连接,很可能是服务器重启
117
+ this.deploymentDetected = true
118
+ this.api.logger.info('[EyeClaw] 🔄 Server restart detected (unclean close), will reconnect immediately')
119
+ }
120
+ } else if (!this.wasConnected && !wasClean) {
121
+ // 首次连接失败,增加重试延迟
122
+ this.api.logger.warn('[EyeClaw] Initial connection failed, increasing delay...')
123
+ }
124
+
80
125
  this.scheduleReconnect()
81
126
  }
82
127
 
@@ -93,11 +138,20 @@ export class EyeClawWebSocketClient {
93
138
  this.stopPing()
94
139
  this.reconnecting = false
95
140
  this.resetReconnectDelay()
141
+
142
+ // 清理所有定时器
143
+ if (this.reconnectTimer) {
144
+ clearTimeout(this.reconnectTimer)
145
+ this.reconnectTimer = null
146
+ }
147
+ // 注意:不再需要清理 healthCheckInterval,因为已合并到 connectionHealthCheckTimer
148
+
96
149
  if (this.ws) {
97
150
  this.ws.close()
98
151
  this.ws = null
99
152
  }
100
153
  this.subscribed = false
154
+ this.wasConnected = false
101
155
  }
102
156
 
103
157
  /**
@@ -106,19 +160,30 @@ export class EyeClawWebSocketClient {
106
160
  private resetReconnectDelay() {
107
161
  this.currentReconnectDelay = this.baseReconnectDelay
108
162
  this.reconnectAttempts = 0
163
+ // 注意:不重置 consecutiveFailures,因为这是跨会话的统计
109
164
  }
110
165
 
111
166
  /**
112
- * 计算下一次重连延迟(指数退避 + 随机抖动)
167
+ * 计算下一次重连延迟(指数退避 + 随机抖动 + 智能调整)
113
168
  */
114
169
  private calculateReconnectDelay(): number {
115
- // 指数增长: 1s, 2s, 4s, 8s, 16s, 30s (cap)
116
- const delay = Math.min(
170
+ // 基础延迟:1s, 2s, 4s, 8s, 16s, 30s (cap)
171
+ let delay = Math.min(
117
172
  this.currentReconnectDelay * 2,
118
173
  this.maxReconnectDelay
119
174
  )
120
175
  this.currentReconnectDelay = delay
121
176
 
177
+ // 如果连续失败多次,增加额外延迟
178
+ if (this.consecutiveFailures > 3) {
179
+ delay = Math.min(delay * 1.5, this.maxReconnectDelay)
180
+ }
181
+
182
+ // 如果上次关闭码不是正常关闭(1000/1001),增加延迟
183
+ if (this.lastCloseCode !== 0 && this.lastCloseCode !== 1000 && this.lastCloseCode !== 1001) {
184
+ delay = Math.min(delay * 1.5, this.maxReconnectDelay)
185
+ }
186
+
122
187
  // 添加随机抖动 (±25%)
123
188
  const jitter = delay * 0.25 * (Math.random() * 2 - 1)
124
189
  return Math.floor(delay + jitter)
@@ -140,13 +205,13 @@ export class EyeClawWebSocketClient {
140
205
 
141
206
  // Ping/pong (WebSocket 协议级别的 ping 由浏览器自动响应,无需手动处理)
142
207
  if (message.type === 'ping') {
143
- this.api.logger.debug('[EyeClaw] Received protocol-level ping (auto-handled by WebSocket)')
208
+ this.api.logger?.debug?.('[EyeClaw] Received protocol-level ping (auto-handled by WebSocket)')
144
209
  return
145
210
  }
146
211
 
147
212
  // 处理 Rails BotChannel 的 pong 响应
148
213
  if (message.type === 'pong') {
149
- this.api.logger.debug('[EyeClaw] Received pong from server')
214
+ this.api.logger?.debug?.('[EyeClaw] Received pong from server')
150
215
  return
151
216
  }
152
217
 
@@ -172,7 +237,7 @@ export class EyeClawWebSocketClient {
172
237
  if (payload.type === 'chunk_received') {
173
238
  const sequence = payload.sequence
174
239
  this.ackedChunks.add(sequence)
175
- this.api.logger.debug(`[EyeClaw] ✅ Received ACK for chunk #${sequence}, total acked: ${this.ackedChunks.size}/${this.sentChunks}`)
240
+ this.api.logger?.debug?.(`[EyeClaw] ✅ Received ACK for chunk #${sequence}, total acked: ${this.ackedChunks.size}/${this.sentChunks}`)
176
241
  return
177
242
  }
178
243
 
@@ -498,8 +563,12 @@ export class EyeClawWebSocketClient {
498
563
 
499
564
  /**
500
565
  * 启动心跳
566
+ * 心跳间隔设置为 30 秒,比负载均衡器的超时时间短
501
567
  */
502
568
  private startPing() {
569
+ this.stopPing() // 先清理旧的心跳定时器
570
+
571
+ // 主心跳:每 30 秒发送一次(比负载均衡器超时短)
503
572
  this.pingInterval = setInterval(() => {
504
573
  // 调用 Rails BotChannel 的 ping 方法(使用 ActionCable 标准协议)
505
574
  const channelIdentifier = JSON.stringify({
@@ -515,7 +584,58 @@ export class EyeClawWebSocketClient {
515
584
  timestamp: new Date().toISOString(),
516
585
  }),
517
586
  })
518
- }, 60000) // 60秒心跳一次
587
+
588
+ this.api.logger?.debug?.('[EyeClaw] 🔔 Heartbeat sent')
589
+ }, 30000) // 30 秒心跳 - 适合负载均衡器
590
+
591
+ // 连接健康检查:每 10 秒检查一次 WebSocket 状态
592
+ this.connectionHealthCheckTimer = setInterval(() => {
593
+ this.checkConnectionHealth()
594
+ }, 10000)
595
+ }
596
+
597
+ /**
598
+ * 检查 WebSocket 连接健康状态
599
+ * 如果连接异常,自动触发重连
600
+ */
601
+ private checkConnectionHealth() {
602
+ if (!this.ws) {
603
+ this.api.logger?.warn?.('[EyeClaw] ⚠️ No WebSocket instance')
604
+ this.scheduleReconnect()
605
+ return
606
+ }
607
+
608
+ const state = this.ws.readyState
609
+
610
+ if (state === WebSocket.CLOSED || state === WebSocket.CLOSING) {
611
+ this.api.logger?.warn?.(`[EyeClaw] ⚠️ WebSocket state: ${state} (${this.getStateName(state)}), scheduling reconnect`)
612
+ this.scheduleReconnect()
613
+ return
614
+ }
615
+
616
+ // WebSocket.CONNECTING 状态可能持续太久,也需要处理
617
+ if (state === WebSocket.CONNECTING) {
618
+ this.api.logger?.warn?.('[EyeClaw] ⚠️ WebSocket stuck in CONNECTING state for too long')
619
+ // 不立即重连,等待 onclose 触发
620
+ }
621
+
622
+ // CONNECTED 状态正常
623
+ if (state === WebSocket.OPEN) {
624
+ this.api.logger?.debug?.('[EyeClaw] ✅ WebSocket connection healthy')
625
+ }
626
+ }
627
+
628
+ /**
629
+ * 获取 WebSocket 状态名称
630
+ */
631
+ private getStateName(state: number): string {
632
+ switch (state) {
633
+ case WebSocket.CONNECTING: return 'CONNECTING'
634
+ case WebSocket.OPEN: return 'OPEN'
635
+ case WebSocket.CLOSING: return 'CLOSING'
636
+ case WebSocket.CLOSED: return 'CLOSED'
637
+ default: return 'UNKNOWN'
638
+ }
519
639
  }
520
640
 
521
641
  /**
@@ -526,26 +646,47 @@ export class EyeClawWebSocketClient {
526
646
  clearInterval(this.pingInterval)
527
647
  this.pingInterval = null
528
648
  }
649
+ if (this.connectionHealthCheckTimer) {
650
+ clearInterval(this.connectionHealthCheckTimer)
651
+ this.connectionHealthCheckTimer = null
652
+ }
529
653
  }
530
654
 
531
655
  /**
532
- * 计划重连(带指数退避)
656
+ * 计划重连(带指数退避、部署感知加速和健康检查)
533
657
  */
534
658
  private scheduleReconnect() {
535
- // 防止重复调度
659
+ // 防止重复调度 - 先清理旧定时器
660
+ if (this.reconnectTimer) {
661
+ clearTimeout(this.reconnectTimer)
662
+ this.reconnectTimer = null
663
+ }
664
+
536
665
  if (this.reconnecting) {
537
666
  return
538
667
  }
539
668
  this.reconnecting = true
540
669
 
670
+ // 如果超过最大重试次数,继续重试
541
671
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
542
672
  this.api.logger.error('[EyeClaw] Max reconnect attempts reached, will retry later...')
543
- // 不放弃,继续重试(每 60 秒检查一次)
544
- setTimeout(() => {
673
+ // 不放弃,继续重试(每 30 秒检查一次)
674
+ this.reconnectTimer = setTimeout(() => {
545
675
  this.reconnecting = false
546
676
  this.resetReconnectDelay()
547
677
  this.scheduleReconnect()
548
- }, 60000)
678
+ }, 30000)
679
+ return
680
+ }
681
+
682
+ // 🚀 部署恢复场景:立即重连(无延迟)
683
+ if (this.deploymentDetected) {
684
+ this.api.logger.info('[EyeClaw] ⚡ Deployment recovery mode: immediate reconnect')
685
+ this.reconnectTimer = setTimeout(() => {
686
+ this.reconnecting = false
687
+ this.start()
688
+ }, 100) // 100ms 延迟(给服务器一点启动时间)
689
+ this.deploymentDetected = false // 重置标志
549
690
  return
550
691
  }
551
692
 
@@ -553,9 +694,65 @@ export class EyeClawWebSocketClient {
553
694
  this.reconnectAttempts++
554
695
  this.api.logger.info(`[EyeClaw] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
555
696
 
556
- setTimeout(() => {
697
+ this.reconnectTimer = setTimeout(() => {
557
698
  this.reconnecting = false
558
- this.start()
699
+ this.performHealthCheckAndReconnect()
559
700
  }, delay)
560
701
  }
702
+
703
+ /**
704
+ * 健康检查后重连
705
+ */
706
+ private async performHealthCheckAndReconnect() {
707
+ try {
708
+ // 尝试 HTTP 健康检查
709
+ const serverUrl = this.config.serverUrl.replace(/^http/, 'http') // 使用 http 而非 ws
710
+ const healthUrl = `${serverUrl}/api/v1/health`
711
+
712
+ const controller = new AbortController()
713
+ const timeoutId = setTimeout(() => controller.abort(), 3000)
714
+
715
+ const response = await fetch(healthUrl, {
716
+ method: 'GET',
717
+ signal: controller.signal,
718
+ })
719
+
720
+ clearTimeout(timeoutId)
721
+
722
+ if (response.ok) {
723
+ this.api.logger.info('[EyeClaw] ✅ Health check passed, proceeding with reconnect')
724
+ this.start()
725
+ } else {
726
+ this.api.logger.warn(`[EyeClaw] ⚠️ Health check failed (${response.status}), retrying soon...`)
727
+ // 健康检查失败,稍后重试
728
+ this.reconnectTimer = setTimeout(() => {
729
+ this.reconnecting = false
730
+ this.scheduleReconnect()
731
+ }, 2000)
732
+ }
733
+ } catch (error) {
734
+ this.api.logger.warn(`[EyeClaw] ⚠️ Health check error: ${error}, proceeding with reconnect anyway`)
735
+ // 即使健康检查失败也尝试重连
736
+ this.start()
737
+ }
738
+ }
739
+
740
+ /**
741
+ * 主动发送 ping(用于部署恢复后的快速状态同步)
742
+ */
743
+ private sendPing() {
744
+ const channelIdentifier = JSON.stringify({
745
+ channel: 'BotChannel',
746
+ bot_id: this.config.botId,
747
+ })
748
+
749
+ this.send({
750
+ command: 'message',
751
+ identifier: channelIdentifier,
752
+ data: JSON.stringify({
753
+ action: 'ping',
754
+ timestamp: new Date().toISOString(),
755
+ }),
756
+ })
757
+ }
561
758
  }