@eyeclaw/eyeclaw 2.3.12 → 2.4.0

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.0",
4
4
  "description": "EyeClaw plugin for OpenClaw - HTTP SSE streaming + WebSocket client",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -22,12 +22,19 @@ 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
+
35
+ // 🔥 ACK 机制:追踪已发送和已确认的 chunks
36
+ private sentChunks = 0 // 已发送的 chunks 数量
37
+ private ackedChunks = new Set<number>() // 已确认的 chunk 序号集合
31
38
 
32
39
  constructor(api: OpenClawPluginApi, config: EyeClawConfig, getState: () => any) {
33
40
  this.api = api
@@ -84,6 +91,8 @@ export class EyeClawWebSocketClient {
84
91
  */
85
92
  stop() {
86
93
  this.stopPing()
94
+ this.reconnecting = false
95
+ this.resetReconnectDelay()
87
96
  if (this.ws) {
88
97
  this.ws.close()
89
98
  this.ws = null
@@ -91,6 +100,30 @@ export class EyeClawWebSocketClient {
91
100
  this.subscribed = false
92
101
  }
93
102
 
103
+ /**
104
+ * 重置重连延迟
105
+ */
106
+ private resetReconnectDelay() {
107
+ this.currentReconnectDelay = this.baseReconnectDelay
108
+ this.reconnectAttempts = 0
109
+ }
110
+
111
+ /**
112
+ * 计算下一次重连延迟(指数退避 + 随机抖动)
113
+ */
114
+ private calculateReconnectDelay(): number {
115
+ // 指数增长: 1s, 2s, 4s, 8s, 16s, 30s (cap)
116
+ const delay = Math.min(
117
+ this.currentReconnectDelay * 2,
118
+ this.maxReconnectDelay
119
+ )
120
+ this.currentReconnectDelay = delay
121
+
122
+ // 添加随机抖动 (±25%)
123
+ const jitter = delay * 0.25 * (Math.random() * 2 - 1)
124
+ return Math.floor(delay + jitter)
125
+ }
126
+
94
127
  /**
95
128
  * 处理 WebSocket 消息
96
129
  */
@@ -134,6 +167,15 @@ export class EyeClawWebSocketClient {
134
167
  // 实际消息 - 从 Rails 发送的消息
135
168
  if (message.identifier && message.message) {
136
169
  const payload = message.message
170
+
171
+ // 🔥 ACK 机制:处理 chunk_received 确认
172
+ if (payload.type === 'chunk_received') {
173
+ const sequence = payload.sequence
174
+ this.ackedChunks.add(sequence)
175
+ this.api.logger.debug(`[EyeClaw] ✅ Received ACK for chunk #${sequence}, total acked: ${this.ackedChunks.size}/${this.sentChunks}`)
176
+ return
177
+ }
178
+
137
179
  this.handleCommand(payload)
138
180
  }
139
181
 
@@ -197,8 +239,10 @@ export class EyeClawWebSocketClient {
197
239
  * 调用自己的 HTTP 端点 /eyeclaw/chat
198
240
  */
199
241
  private async processWithOpenClaw(message: string, sessionId?: string, openclawSessionId?: string) {
200
- // 重置 chunk 序号(每个新会话)
242
+ // 重置 chunk 序号和 ACK 计数器(每个新会话)
201
243
  this.chunkSequence = 0
244
+ this.sentChunks = 0
245
+ this.ackedChunks.clear()
202
246
 
203
247
  const state = this.getState()
204
248
  const gatewayPort = state.gatewayPort
@@ -240,14 +284,12 @@ export class EyeClawWebSocketClient {
240
284
  let currentEvent = ''
241
285
 
242
286
  // 解析 SSE 流式响应
287
+ let streamEnded = false
288
+
243
289
  while (true) {
244
290
  const { done, value } = await reader.read()
245
291
  if (done) {
246
- // 流结束,通知 Rails
247
- this.sendMessage('stream_end', { session_id: sessionId })
248
-
249
- // 发送 stream_summary 用于兜底机制
250
- this.sendStreamSummary(sessionId)
292
+ this.api.logger.info(`[EyeClaw] Reader done, stream ended flag: ${streamEnded}`)
251
293
  break
252
294
  }
253
295
 
@@ -287,14 +329,25 @@ export class EyeClawWebSocketClient {
287
329
 
288
330
  // stream_end 事件:流结束(由 HTTP handler 发送)
289
331
  if (currentEvent === 'stream_end') {
290
- this.api.logger.info(`[EyeClaw] Stream ended: ${eventData.stream_id}`)
291
- // 通知 Rails 流已结束
332
+ this.api.logger.info(`[EyeClaw] Received stream_end event: ${eventData.stream_id}`)
333
+ streamEnded = true
334
+
335
+ // 🔥 等待所有 chunks 被确认后再发送 stream_end
336
+ await this.waitForAllAcks(sessionId)
337
+
338
+ // 发送 stream_end 和 stream_summary
292
339
  this.sendMessage('stream_end', { session_id: sessionId })
340
+ this.sendStreamSummary(sessionId)
341
+
342
+ // 退出循环
343
+ return
293
344
  }
294
345
 
295
346
  // stream_error 事件:错误
296
347
  if (currentEvent === 'stream_error') {
297
348
  this.api.logger.error(`[EyeClaw] Stream error: ${eventData.error}`)
349
+ this.sendMessage('stream_error', { error: eventData.error, session_id: sessionId })
350
+ return
298
351
  }
299
352
  } catch (e) {
300
353
  this.api.logger.warn(`[EyeClaw] Failed to parse SSE data: ${data}`)
@@ -302,6 +355,14 @@ export class EyeClawWebSocketClient {
302
355
  }
303
356
  }
304
357
  }
358
+
359
+ // 如果循环正常结束(没有收到 stream_end 事件),也要等待 ACK
360
+ if (!streamEnded) {
361
+ this.api.logger.info(`[EyeClaw] Stream ended without stream_end event, waiting for ACKs`)
362
+ await this.waitForAllAcks(sessionId)
363
+ this.sendMessage('stream_end', { session_id: sessionId })
364
+ this.sendStreamSummary(sessionId)
365
+ }
305
366
 
306
367
  this.api.logger.info(`[EyeClaw] Stream processing completed for session: ${sessionId}`)
307
368
 
@@ -326,6 +387,7 @@ export class EyeClawWebSocketClient {
326
387
  private sendChunk(content: string, sessionId?: string) {
327
388
  const timestamp = new Date().toISOString();
328
389
  const sequence = this.chunkSequence++;
390
+ this.sentChunks++; // 🔥 记录已发送数量
329
391
 
330
392
  // 累积完整内容用于兜底
331
393
  this.accumulatedContent += content;
@@ -338,6 +400,51 @@ export class EyeClawWebSocketClient {
338
400
  })
339
401
  }
340
402
 
403
+ /**
404
+ * 🔥 等待所有 chunks 被 Rails 确认
405
+ * 实现 TCP 三次握手的应用层版本
406
+ * 超时 2 秒后强制返回,依赖 stream_summary 兜底机制
407
+ */
408
+ private async waitForAllAcks(sessionId?: string): Promise<void> {
409
+ const startTime = Date.now()
410
+ const timeout = 2000 // 2秒超时
411
+ const checkInterval = 50 // 每 50ms 检查一次
412
+
413
+ this.api.logger.info(`[EyeClaw] 🕒 Waiting for all ACKs: sent=${this.sentChunks}, acked=${this.ackedChunks.size}`)
414
+
415
+ while (this.ackedChunks.size < this.sentChunks) {
416
+ const elapsed = Date.now() - startTime
417
+
418
+ if (elapsed >= timeout) {
419
+ const missing = this.sentChunks - this.ackedChunks.size
420
+ const missingSequences: number[] = []
421
+ for (let i = 0; i < this.sentChunks; i++) {
422
+ if (!this.ackedChunks.has(i)) {
423
+ missingSequences.push(i)
424
+ }
425
+ }
426
+
427
+ this.api.logger.warn(
428
+ `[EyeClaw] ⚠️ ACK timeout after ${elapsed}ms: ` +
429
+ `sent=${this.sentChunks}, acked=${this.ackedChunks.size}, ` +
430
+ `missing=${missing}, missing_sequences=[${missingSequences.join(', ')}]`
431
+ )
432
+ this.api.logger.info(`[EyeClaw] Relying on stream_summary fallback mechanism`)
433
+ break
434
+ }
435
+
436
+ // 等待 50ms 后再检查
437
+ await new Promise(resolve => setTimeout(resolve, checkInterval))
438
+ }
439
+
440
+ if (this.ackedChunks.size === this.sentChunks) {
441
+ const elapsed = Date.now() - startTime
442
+ this.api.logger.info(
443
+ `[EyeClaw] ✅ All chunks ACKed: ${this.ackedChunks.size}/${this.sentChunks} in ${elapsed}ms`
444
+ )
445
+ }
446
+ }
447
+
341
448
  /**
342
449
  * 发送 stream_summary 用于兜底机制
343
450
  * 告诉 Rails 完整内容是什么,以便检测丢包并补偿
@@ -422,19 +529,33 @@ export class EyeClawWebSocketClient {
422
529
  }
423
530
 
424
531
  /**
425
- * 计划重连
532
+ * 计划重连(带指数退避)
426
533
  */
427
534
  private scheduleReconnect() {
535
+ // 防止重复调度
536
+ if (this.reconnecting) {
537
+ return
538
+ }
539
+ this.reconnecting = true
540
+
428
541
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
429
- this.api.logger.error('[EyeClaw] Max reconnect attempts reached')
542
+ this.api.logger.error('[EyeClaw] Max reconnect attempts reached, will retry later...')
543
+ // 不放弃,继续重试(每 60 秒检查一次)
544
+ setTimeout(() => {
545
+ this.reconnecting = false
546
+ this.resetReconnectDelay()
547
+ this.scheduleReconnect()
548
+ }, 60000)
430
549
  return
431
550
  }
432
551
 
552
+ const delay = this.calculateReconnectDelay()
433
553
  this.reconnectAttempts++
434
- this.api.logger.info(`[EyeClaw] Reconnecting in ${this.reconnectDelay}ms (attempt ${this.reconnectAttempts})`)
554
+ this.api.logger.info(`[EyeClaw] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
435
555
 
436
556
  setTimeout(() => {
557
+ this.reconnecting = false
437
558
  this.start()
438
- }, this.reconnectDelay)
559
+ }, delay)
439
560
  }
440
561
  }