@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 +1 -1
- package/src/websocket-client.ts +212 -15
package/package.json
CHANGED
package/src/websocket-client.ts
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
116
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
// 不放弃,继续重试(每
|
|
544
|
-
setTimeout(() => {
|
|
673
|
+
// 不放弃,继续重试(每 30 秒检查一次)
|
|
674
|
+
this.reconnectTimer = setTimeout(() => {
|
|
545
675
|
this.reconnecting = false
|
|
546
676
|
this.resetReconnectDelay()
|
|
547
677
|
this.scheduleReconnect()
|
|
548
|
-
},
|
|
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.
|
|
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
|
}
|