@eyeclaw/eyeclaw 2.3.12 → 2.4.1

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.3.12",
3
+ "version": "2.4.1",
4
4
  "description": "EyeClaw plugin for OpenClaw - HTTP SSE streaming + WebSocket client",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -22,12 +22,25 @@ export class EyeClawWebSocketClient {
22
22
  private config: EyeClawConfig
23
23
  private getState: () => any
24
24
  private reconnectAttempts = 0
25
- private maxReconnectAttempts = 5
26
- private reconnectDelay = 3000
25
+ private maxReconnectAttempts = 10
26
+ private baseReconnectDelay = 1000 // 1 second
27
+ private maxReconnectDelay = 30000 // 30 seconds
28
+ private currentReconnectDelay = 1000
29
+ private reconnecting = false
27
30
  private subscribed = false
28
31
  private pingInterval: any = null
29
32
  private chunkSequence = 0 // 每个会话的 chunk 序号
30
33
  private accumulatedContent = '' // 累积完整内容用于兜底
34
+ private lastConnectedAt = 0 // 上次连接成功时间戳
35
+ private deploymentDetected = false // 是否检测到部署重启
36
+ private wasConnected = false // 之前是否连接成功过
37
+ private reconnectTimer: any = null // 重连定时器引用
38
+ private healthCheckInterval: any = null // 健康检查定时器
39
+ private serverVersion = '' // 服务器版本(用于检测部署)
40
+
41
+ // 🔥 ACK 机制:追踪已发送和已确认的 chunks
42
+ private sentChunks = 0 // 已发送的 chunks 数量
43
+ private ackedChunks = new Set<number>() // 已确认的 chunk 序号集合
31
44
 
32
45
  constructor(api: OpenClawPluginApi, config: EyeClawConfig, getState: () => any) {
33
46
  this.api = api
@@ -56,6 +69,16 @@ export class EyeClawWebSocketClient {
56
69
  this.ws.onopen = () => {
57
70
  this.api.logger.info('[EyeClaw] WebSocket connected')
58
71
  this.reconnectAttempts = 0
72
+ this.lastConnectedAt = Date.now()
73
+ this.wasConnected = true
74
+
75
+ // 如果检测到部署重启(之前已连接过),立即触发一次快速心跳
76
+ if (this.deploymentDetected) {
77
+ this.api.logger.info('[EyeClaw] 🚀 Deployment recovery detected, sending immediate ping')
78
+ this.deploymentDetected = false
79
+ // 延迟 500ms 确保订阅完成后再发心跳
80
+ setTimeout(() => this.sendPing(), 500)
81
+ }
59
82
  }
60
83
 
61
84
  this.ws.onmessage = (event) => {
@@ -70,6 +93,13 @@ export class EyeClawWebSocketClient {
70
93
  this.api.logger.warn('[EyeClaw] WebSocket disconnected')
71
94
  this.subscribed = false
72
95
  this.stopPing()
96
+
97
+ // 检测是否是部署导致的断连(之前已成功连接过)
98
+ if (this.wasConnected && Date.now() - this.lastConnectedAt > 5000) {
99
+ this.deploymentDetected = true
100
+ this.api.logger.info('[EyeClaw] 🔄 Deployment detected, will reconnect immediately')
101
+ }
102
+
73
103
  this.scheduleReconnect()
74
104
  }
75
105
 
@@ -84,11 +114,49 @@ export class EyeClawWebSocketClient {
84
114
  */
85
115
  stop() {
86
116
  this.stopPing()
117
+ this.reconnecting = false
118
+ this.resetReconnectDelay()
119
+
120
+ // 清理所有定时器
121
+ if (this.reconnectTimer) {
122
+ clearTimeout(this.reconnectTimer)
123
+ this.reconnectTimer = null
124
+ }
125
+ if (this.healthCheckInterval) {
126
+ clearInterval(this.healthCheckInterval)
127
+ this.healthCheckInterval = null
128
+ }
129
+
87
130
  if (this.ws) {
88
131
  this.ws.close()
89
132
  this.ws = null
90
133
  }
91
134
  this.subscribed = false
135
+ this.wasConnected = false
136
+ }
137
+
138
+ /**
139
+ * 重置重连延迟
140
+ */
141
+ private resetReconnectDelay() {
142
+ this.currentReconnectDelay = this.baseReconnectDelay
143
+ this.reconnectAttempts = 0
144
+ }
145
+
146
+ /**
147
+ * 计算下一次重连延迟(指数退避 + 随机抖动)
148
+ */
149
+ private calculateReconnectDelay(): number {
150
+ // 指数增长: 1s, 2s, 4s, 8s, 16s, 30s (cap)
151
+ const delay = Math.min(
152
+ this.currentReconnectDelay * 2,
153
+ this.maxReconnectDelay
154
+ )
155
+ this.currentReconnectDelay = delay
156
+
157
+ // 添加随机抖动 (±25%)
158
+ const jitter = delay * 0.25 * (Math.random() * 2 - 1)
159
+ return Math.floor(delay + jitter)
92
160
  }
93
161
 
94
162
  /**
@@ -107,13 +175,13 @@ export class EyeClawWebSocketClient {
107
175
 
108
176
  // Ping/pong (WebSocket 协议级别的 ping 由浏览器自动响应,无需手动处理)
109
177
  if (message.type === 'ping') {
110
- this.api.logger.debug('[EyeClaw] Received protocol-level ping (auto-handled by WebSocket)')
178
+ this.api.logger?.debug?.('[EyeClaw] Received protocol-level ping (auto-handled by WebSocket)')
111
179
  return
112
180
  }
113
181
 
114
182
  // 处理 Rails BotChannel 的 pong 响应
115
183
  if (message.type === 'pong') {
116
- this.api.logger.debug('[EyeClaw] Received pong from server')
184
+ this.api.logger?.debug?.('[EyeClaw] Received pong from server')
117
185
  return
118
186
  }
119
187
 
@@ -134,6 +202,15 @@ export class EyeClawWebSocketClient {
134
202
  // 实际消息 - 从 Rails 发送的消息
135
203
  if (message.identifier && message.message) {
136
204
  const payload = message.message
205
+
206
+ // 🔥 ACK 机制:处理 chunk_received 确认
207
+ if (payload.type === 'chunk_received') {
208
+ const sequence = payload.sequence
209
+ this.ackedChunks.add(sequence)
210
+ this.api.logger?.debug?.(`[EyeClaw] ✅ Received ACK for chunk #${sequence}, total acked: ${this.ackedChunks.size}/${this.sentChunks}`)
211
+ return
212
+ }
213
+
137
214
  this.handleCommand(payload)
138
215
  }
139
216
 
@@ -197,8 +274,10 @@ export class EyeClawWebSocketClient {
197
274
  * 调用自己的 HTTP 端点 /eyeclaw/chat
198
275
  */
199
276
  private async processWithOpenClaw(message: string, sessionId?: string, openclawSessionId?: string) {
200
- // 重置 chunk 序号(每个新会话)
277
+ // 重置 chunk 序号和 ACK 计数器(每个新会话)
201
278
  this.chunkSequence = 0
279
+ this.sentChunks = 0
280
+ this.ackedChunks.clear()
202
281
 
203
282
  const state = this.getState()
204
283
  const gatewayPort = state.gatewayPort
@@ -240,14 +319,12 @@ export class EyeClawWebSocketClient {
240
319
  let currentEvent = ''
241
320
 
242
321
  // 解析 SSE 流式响应
322
+ let streamEnded = false
323
+
243
324
  while (true) {
244
325
  const { done, value } = await reader.read()
245
326
  if (done) {
246
- // 流结束,通知 Rails
247
- this.sendMessage('stream_end', { session_id: sessionId })
248
-
249
- // 发送 stream_summary 用于兜底机制
250
- this.sendStreamSummary(sessionId)
327
+ this.api.logger.info(`[EyeClaw] Reader done, stream ended flag: ${streamEnded}`)
251
328
  break
252
329
  }
253
330
 
@@ -287,14 +364,25 @@ export class EyeClawWebSocketClient {
287
364
 
288
365
  // stream_end 事件:流结束(由 HTTP handler 发送)
289
366
  if (currentEvent === 'stream_end') {
290
- this.api.logger.info(`[EyeClaw] Stream ended: ${eventData.stream_id}`)
291
- // 通知 Rails 流已结束
367
+ this.api.logger.info(`[EyeClaw] Received stream_end event: ${eventData.stream_id}`)
368
+ streamEnded = true
369
+
370
+ // 🔥 等待所有 chunks 被确认后再发送 stream_end
371
+ await this.waitForAllAcks(sessionId)
372
+
373
+ // 发送 stream_end 和 stream_summary
292
374
  this.sendMessage('stream_end', { session_id: sessionId })
375
+ this.sendStreamSummary(sessionId)
376
+
377
+ // 退出循环
378
+ return
293
379
  }
294
380
 
295
381
  // stream_error 事件:错误
296
382
  if (currentEvent === 'stream_error') {
297
383
  this.api.logger.error(`[EyeClaw] Stream error: ${eventData.error}`)
384
+ this.sendMessage('stream_error', { error: eventData.error, session_id: sessionId })
385
+ return
298
386
  }
299
387
  } catch (e) {
300
388
  this.api.logger.warn(`[EyeClaw] Failed to parse SSE data: ${data}`)
@@ -302,6 +390,14 @@ export class EyeClawWebSocketClient {
302
390
  }
303
391
  }
304
392
  }
393
+
394
+ // 如果循环正常结束(没有收到 stream_end 事件),也要等待 ACK
395
+ if (!streamEnded) {
396
+ this.api.logger.info(`[EyeClaw] Stream ended without stream_end event, waiting for ACKs`)
397
+ await this.waitForAllAcks(sessionId)
398
+ this.sendMessage('stream_end', { session_id: sessionId })
399
+ this.sendStreamSummary(sessionId)
400
+ }
305
401
 
306
402
  this.api.logger.info(`[EyeClaw] Stream processing completed for session: ${sessionId}`)
307
403
 
@@ -326,6 +422,7 @@ export class EyeClawWebSocketClient {
326
422
  private sendChunk(content: string, sessionId?: string) {
327
423
  const timestamp = new Date().toISOString();
328
424
  const sequence = this.chunkSequence++;
425
+ this.sentChunks++; // 🔥 记录已发送数量
329
426
 
330
427
  // 累积完整内容用于兜底
331
428
  this.accumulatedContent += content;
@@ -338,6 +435,51 @@ export class EyeClawWebSocketClient {
338
435
  })
339
436
  }
340
437
 
438
+ /**
439
+ * 🔥 等待所有 chunks 被 Rails 确认
440
+ * 实现 TCP 三次握手的应用层版本
441
+ * 超时 2 秒后强制返回,依赖 stream_summary 兜底机制
442
+ */
443
+ private async waitForAllAcks(sessionId?: string): Promise<void> {
444
+ const startTime = Date.now()
445
+ const timeout = 2000 // 2秒超时
446
+ const checkInterval = 50 // 每 50ms 检查一次
447
+
448
+ this.api.logger.info(`[EyeClaw] 🕒 Waiting for all ACKs: sent=${this.sentChunks}, acked=${this.ackedChunks.size}`)
449
+
450
+ while (this.ackedChunks.size < this.sentChunks) {
451
+ const elapsed = Date.now() - startTime
452
+
453
+ if (elapsed >= timeout) {
454
+ const missing = this.sentChunks - this.ackedChunks.size
455
+ const missingSequences: number[] = []
456
+ for (let i = 0; i < this.sentChunks; i++) {
457
+ if (!this.ackedChunks.has(i)) {
458
+ missingSequences.push(i)
459
+ }
460
+ }
461
+
462
+ this.api.logger.warn(
463
+ `[EyeClaw] ⚠️ ACK timeout after ${elapsed}ms: ` +
464
+ `sent=${this.sentChunks}, acked=${this.ackedChunks.size}, ` +
465
+ `missing=${missing}, missing_sequences=[${missingSequences.join(', ')}]`
466
+ )
467
+ this.api.logger.info(`[EyeClaw] Relying on stream_summary fallback mechanism`)
468
+ break
469
+ }
470
+
471
+ // 等待 50ms 后再检查
472
+ await new Promise(resolve => setTimeout(resolve, checkInterval))
473
+ }
474
+
475
+ if (this.ackedChunks.size === this.sentChunks) {
476
+ const elapsed = Date.now() - startTime
477
+ this.api.logger.info(
478
+ `[EyeClaw] ✅ All chunks ACKed: ${this.ackedChunks.size}/${this.sentChunks} in ${elapsed}ms`
479
+ )
480
+ }
481
+ }
482
+
341
483
  /**
342
484
  * 发送 stream_summary 用于兜底机制
343
485
  * 告诉 Rails 完整内容是什么,以便检测丢包并补偿
@@ -422,19 +564,106 @@ export class EyeClawWebSocketClient {
422
564
  }
423
565
 
424
566
  /**
425
- * 计划重连
567
+ * 计划重连(带指数退避、部署感知加速和健康检查)
426
568
  */
427
569
  private scheduleReconnect() {
570
+ // 防止重复调度 - 先清理旧定时器
571
+ if (this.reconnectTimer) {
572
+ clearTimeout(this.reconnectTimer)
573
+ this.reconnectTimer = null
574
+ }
575
+
576
+ if (this.reconnecting) {
577
+ return
578
+ }
579
+ this.reconnecting = true
580
+
581
+ // 如果超过最大重试次数,继续重试
428
582
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
429
- this.api.logger.error('[EyeClaw] Max reconnect attempts reached')
583
+ this.api.logger.error('[EyeClaw] Max reconnect attempts reached, will retry later...')
584
+ // 不放弃,继续重试(每 30 秒检查一次)
585
+ this.reconnectTimer = setTimeout(() => {
586
+ this.reconnecting = false
587
+ this.resetReconnectDelay()
588
+ this.scheduleReconnect()
589
+ }, 30000)
590
+ return
591
+ }
592
+
593
+ // 🚀 部署恢复场景:立即重连(无延迟)
594
+ if (this.deploymentDetected) {
595
+ this.api.logger.info('[EyeClaw] ⚡ Deployment recovery mode: immediate reconnect')
596
+ this.reconnectTimer = setTimeout(() => {
597
+ this.reconnecting = false
598
+ this.start()
599
+ }, 100) // 100ms 延迟(给服务器一点启动时间)
600
+ this.deploymentDetected = false // 重置标志
430
601
  return
431
602
  }
432
603
 
604
+ const delay = this.calculateReconnectDelay()
433
605
  this.reconnectAttempts++
434
- this.api.logger.info(`[EyeClaw] Reconnecting in ${this.reconnectDelay}ms (attempt ${this.reconnectAttempts})`)
606
+ this.api.logger.info(`[EyeClaw] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
435
607
 
436
- setTimeout(() => {
608
+ this.reconnectTimer = setTimeout(() => {
609
+ this.reconnecting = false
610
+ this.performHealthCheckAndReconnect()
611
+ }, delay)
612
+ }
613
+
614
+ /**
615
+ * 健康检查后重连
616
+ */
617
+ private async performHealthCheckAndReconnect() {
618
+ try {
619
+ // 尝试 HTTP 健康检查
620
+ const serverUrl = this.config.serverUrl.replace(/^http/, 'http') // 使用 http 而非 ws
621
+ const healthUrl = `${serverUrl}/api/v1/health`
622
+
623
+ const controller = new AbortController()
624
+ const timeoutId = setTimeout(() => controller.abort(), 3000)
625
+
626
+ const response = await fetch(healthUrl, {
627
+ method: 'GET',
628
+ signal: controller.signal,
629
+ })
630
+
631
+ clearTimeout(timeoutId)
632
+
633
+ if (response.ok) {
634
+ this.api.logger.info('[EyeClaw] ✅ Health check passed, proceeding with reconnect')
635
+ this.start()
636
+ } else {
637
+ this.api.logger.warn(`[EyeClaw] ⚠️ Health check failed (${response.status}), retrying soon...`)
638
+ // 健康检查失败,稍后重试
639
+ this.reconnectTimer = setTimeout(() => {
640
+ this.reconnecting = false
641
+ this.scheduleReconnect()
642
+ }, 2000)
643
+ }
644
+ } catch (error) {
645
+ this.api.logger.warn(`[EyeClaw] ⚠️ Health check error: ${error}, proceeding with reconnect anyway`)
646
+ // 即使健康检查失败也尝试重连
437
647
  this.start()
438
- }, this.reconnectDelay)
648
+ }
649
+ }
650
+
651
+ /**
652
+ * 主动发送 ping(用于部署恢复后的快速状态同步)
653
+ */
654
+ private sendPing() {
655
+ const channelIdentifier = JSON.stringify({
656
+ channel: 'BotChannel',
657
+ bot_id: this.config.botId,
658
+ })
659
+
660
+ this.send({
661
+ command: 'message',
662
+ identifier: channelIdentifier,
663
+ data: JSON.stringify({
664
+ action: 'ping',
665
+ timestamp: new Date().toISOString(),
666
+ }),
667
+ })
439
668
  }
440
669
  }