@eyeclaw/eyeclaw 2.4.1 → 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 +102 -13
package/package.json
CHANGED
package/src/websocket-client.ts
CHANGED
|
@@ -29,14 +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 = '' // 累积完整内容用于兜底
|
|
34
35
|
private lastConnectedAt = 0 // 上次连接成功时间戳
|
|
35
36
|
private deploymentDetected = false // 是否检测到部署重启
|
|
36
37
|
private wasConnected = false // 之前是否连接成功过
|
|
37
38
|
private reconnectTimer: any = null // 重连定时器引用
|
|
38
|
-
private healthCheckInterval: any = null // 健康检查定时器
|
|
39
39
|
private serverVersion = '' // 服务器版本(用于检测部署)
|
|
40
|
+
private consecutiveFailures = 0 // 连续失败次数
|
|
41
|
+
private lastCloseCode = 0 // 上次关闭码
|
|
40
42
|
|
|
41
43
|
// 🔥 ACK 机制:追踪已发送和已确认的 chunks
|
|
42
44
|
private sentChunks = 0 // 已发送的 chunks 数量
|
|
@@ -69,6 +71,7 @@ export class EyeClawWebSocketClient {
|
|
|
69
71
|
this.ws.onopen = () => {
|
|
70
72
|
this.api.logger.info('[EyeClaw] WebSocket connected')
|
|
71
73
|
this.reconnectAttempts = 0
|
|
74
|
+
this.consecutiveFailures = 0 // 重置连续失败计数
|
|
72
75
|
this.lastConnectedAt = Date.now()
|
|
73
76
|
this.wasConnected = true
|
|
74
77
|
|
|
@@ -90,14 +93,33 @@ export class EyeClawWebSocketClient {
|
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
this.ws.onclose = () => {
|
|
93
|
-
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
|
+
|
|
94
101
|
this.subscribed = false
|
|
95
102
|
this.stopPing()
|
|
103
|
+
this.lastCloseCode = closeCode
|
|
104
|
+
|
|
105
|
+
// 统计连续失败
|
|
106
|
+
this.consecutiveFailures++
|
|
96
107
|
|
|
97
|
-
//
|
|
108
|
+
// 检测是否是部署导致的断连(之前已成功连接过,且断开时间 > 5 秒)
|
|
109
|
+
// 或者关闭码为 1000/1001(正常关闭)但之前已连接
|
|
98
110
|
if (this.wasConnected && Date.now() - this.lastConnectedAt > 5000) {
|
|
99
|
-
|
|
100
|
-
|
|
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...')
|
|
101
123
|
}
|
|
102
124
|
|
|
103
125
|
this.scheduleReconnect()
|
|
@@ -122,10 +144,7 @@ export class EyeClawWebSocketClient {
|
|
|
122
144
|
clearTimeout(this.reconnectTimer)
|
|
123
145
|
this.reconnectTimer = null
|
|
124
146
|
}
|
|
125
|
-
|
|
126
|
-
clearInterval(this.healthCheckInterval)
|
|
127
|
-
this.healthCheckInterval = null
|
|
128
|
-
}
|
|
147
|
+
// 注意:不再需要清理 healthCheckInterval,因为已合并到 connectionHealthCheckTimer
|
|
129
148
|
|
|
130
149
|
if (this.ws) {
|
|
131
150
|
this.ws.close()
|
|
@@ -141,19 +160,30 @@ export class EyeClawWebSocketClient {
|
|
|
141
160
|
private resetReconnectDelay() {
|
|
142
161
|
this.currentReconnectDelay = this.baseReconnectDelay
|
|
143
162
|
this.reconnectAttempts = 0
|
|
163
|
+
// 注意:不重置 consecutiveFailures,因为这是跨会话的统计
|
|
144
164
|
}
|
|
145
165
|
|
|
146
166
|
/**
|
|
147
|
-
* 计算下一次重连延迟(指数退避 +
|
|
167
|
+
* 计算下一次重连延迟(指数退避 + 随机抖动 + 智能调整)
|
|
148
168
|
*/
|
|
149
169
|
private calculateReconnectDelay(): number {
|
|
150
|
-
//
|
|
151
|
-
|
|
170
|
+
// 基础延迟:1s, 2s, 4s, 8s, 16s, 30s (cap)
|
|
171
|
+
let delay = Math.min(
|
|
152
172
|
this.currentReconnectDelay * 2,
|
|
153
173
|
this.maxReconnectDelay
|
|
154
174
|
)
|
|
155
175
|
this.currentReconnectDelay = delay
|
|
156
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
|
+
|
|
157
187
|
// 添加随机抖动 (±25%)
|
|
158
188
|
const jitter = delay * 0.25 * (Math.random() * 2 - 1)
|
|
159
189
|
return Math.floor(delay + jitter)
|
|
@@ -533,8 +563,12 @@ export class EyeClawWebSocketClient {
|
|
|
533
563
|
|
|
534
564
|
/**
|
|
535
565
|
* 启动心跳
|
|
566
|
+
* 心跳间隔设置为 30 秒,比负载均衡器的超时时间短
|
|
536
567
|
*/
|
|
537
568
|
private startPing() {
|
|
569
|
+
this.stopPing() // 先清理旧的心跳定时器
|
|
570
|
+
|
|
571
|
+
// 主心跳:每 30 秒发送一次(比负载均衡器超时短)
|
|
538
572
|
this.pingInterval = setInterval(() => {
|
|
539
573
|
// 调用 Rails BotChannel 的 ping 方法(使用 ActionCable 标准协议)
|
|
540
574
|
const channelIdentifier = JSON.stringify({
|
|
@@ -550,7 +584,58 @@ export class EyeClawWebSocketClient {
|
|
|
550
584
|
timestamp: new Date().toISOString(),
|
|
551
585
|
}),
|
|
552
586
|
})
|
|
553
|
-
|
|
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
|
+
}
|
|
554
639
|
}
|
|
555
640
|
|
|
556
641
|
/**
|
|
@@ -561,6 +646,10 @@ export class EyeClawWebSocketClient {
|
|
|
561
646
|
clearInterval(this.pingInterval)
|
|
562
647
|
this.pingInterval = null
|
|
563
648
|
}
|
|
649
|
+
if (this.connectionHealthCheckTimer) {
|
|
650
|
+
clearInterval(this.connectionHealthCheckTimer)
|
|
651
|
+
this.connectionHealthCheckTimer = null
|
|
652
|
+
}
|
|
564
653
|
}
|
|
565
654
|
|
|
566
655
|
/**
|