@cocorograph/hub-agent 0.8.45 → 0.8.47
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/codex-appserver-client.mjs +12 -5
- package/src/codex-binary.mjs +90 -0
- package/src/codex-stream-bridge.mjs +4 -5
- package/src/config.mjs +10 -4
- package/src/hub-bundle.mjs +18 -3
- package/src/main.mjs +130 -61
- package/src/tmux.mjs +45 -12
- package/src/usage.mjs +169 -31
- package/src/ws-client.mjs +136 -37
package/src/ws-client.mjs
CHANGED
|
@@ -143,6 +143,7 @@ export class WsClient extends EventEmitter {
|
|
|
143
143
|
this.bundleStatus = {}
|
|
144
144
|
this.bundleManifestPath = opts.bundleManifestPath || null
|
|
145
145
|
this.bundleWatcher = null
|
|
146
|
+
this.bundleWatcherConnectionMeta = null
|
|
146
147
|
this.bundleWatchDebounceTimer = null
|
|
147
148
|
this.hostname = opts.hostname || os.hostname()
|
|
148
149
|
this.platform = opts.platform || "unknown"
|
|
@@ -171,6 +172,10 @@ export class WsClient extends EventEmitter {
|
|
|
171
172
|
this.lastRecvAt = 0
|
|
172
173
|
// heartbeat tick の直前実発火時刻。スリープ復帰のジャンプ検知に使う。
|
|
173
174
|
this.lastTickAt = 0
|
|
175
|
+
// close ログを接続世代ごとに完結させる。古い socket の close が遅れて届いても、
|
|
176
|
+
// 新しい接続の診断状態や timer を壊さないよう socket のクロージャに保持する。
|
|
177
|
+
this.connectionGeneration = 0
|
|
178
|
+
this.connectionMeta = this._newConnectionMeta(0)
|
|
174
179
|
}
|
|
175
180
|
|
|
176
181
|
/** WSS 接続を開始する。`stop()` まで自動で reconnect 続行。 */
|
|
@@ -183,14 +188,21 @@ export class WsClient extends EventEmitter {
|
|
|
183
188
|
Authorization: `Bearer ${this.config.agent_id}:${this.config.agent_token}`,
|
|
184
189
|
}
|
|
185
190
|
const ws = new WebSocket(wsUrl, { headers })
|
|
191
|
+
const connectionMeta = this._newConnectionMeta(++this.connectionGeneration)
|
|
186
192
|
this.ws = ws
|
|
193
|
+
this.connectionMeta = connectionMeta
|
|
187
194
|
|
|
188
|
-
ws.on("open", () =>
|
|
195
|
+
ws.on("open", () => {
|
|
196
|
+
if (!this._isActiveConnection(ws, connectionMeta)) return
|
|
197
|
+
this._onOpen(connectionMeta)
|
|
198
|
+
})
|
|
189
199
|
|
|
190
200
|
ws.on("message", (data) => {
|
|
201
|
+
if (!this._isActiveConnection(ws, connectionMeta)) return
|
|
191
202
|
// 何かを受信した = TCP は生きている。zombie 検知の鮮度を更新する
|
|
192
203
|
// (parse 失敗でも「届いた」事実は生存の証拠なので先に記録する)。
|
|
193
204
|
this.lastRecvAt = Date.now()
|
|
205
|
+
connectionMeta.lastRecvAt = this.lastRecvAt
|
|
194
206
|
let msg
|
|
195
207
|
try {
|
|
196
208
|
msg = JSON.parse(data.toString("utf-8"))
|
|
@@ -202,13 +214,7 @@ export class WsClient extends EventEmitter {
|
|
|
202
214
|
})
|
|
203
215
|
|
|
204
216
|
ws.on("close", (code, reason) => {
|
|
205
|
-
this.
|
|
206
|
-
this._clearStableReset()
|
|
207
|
-
this._stopHeartbeat()
|
|
208
|
-
this._stopBundleWatcher()
|
|
209
|
-
this.logger?.info({ code, reason: reason?.toString() }, "ws close")
|
|
210
|
-
this.emit("close", { code, reason })
|
|
211
|
-
if (!this.stopped) this._scheduleReconnect()
|
|
217
|
+
this._onSocketClose(ws, connectionMeta, code, reason)
|
|
212
218
|
})
|
|
213
219
|
|
|
214
220
|
ws.on("error", (err) => {
|
|
@@ -216,7 +222,10 @@ export class WsClient extends EventEmitter {
|
|
|
216
222
|
// Upgrade で CF/origin が 5xx を返した場合 (典型: "Unexpected server
|
|
217
223
|
// response: 502") は、次回 reconnect の最低 backoff を引き上げる。
|
|
218
224
|
// CF が 5xx を short-cache するため、1s 連発リトライは逆に詰まる。
|
|
219
|
-
if (
|
|
225
|
+
if (
|
|
226
|
+
this._isActiveConnection(ws, connectionMeta) &&
|
|
227
|
+
/Unexpected server response: 5\d\d/.test(err?.message || "")
|
|
228
|
+
) {
|
|
220
229
|
this.lastCloseWas5xx = true
|
|
221
230
|
}
|
|
222
231
|
this.emit("error", err)
|
|
@@ -235,14 +244,23 @@ export class WsClient extends EventEmitter {
|
|
|
235
244
|
* test からも直接呼べるように method として切り出している (`ws.on("open")`
|
|
236
245
|
* のクロージャだと spy しづらい)。
|
|
237
246
|
*/
|
|
238
|
-
_onOpen() {
|
|
247
|
+
_onOpen(connectionMeta = this.connectionMeta) {
|
|
239
248
|
// open 自体が生存の証拠。直前接続の古い lastRecvAt で開幕直後に zombie 誤検知
|
|
240
249
|
// しないようリセットする。⚠️ _armStableReset() の armedAt 捕捉より「前」に設定すること。
|
|
241
250
|
// ack ベース判定 (lastRecvAt > armedAt) を初期状態で偽にするため (順序を逆にすると
|
|
242
251
|
// armedAt < lastRecvAt となり「inbound 受信済み」と誤判定し、half-open でも backoff を
|
|
243
252
|
// リセットしてしまう)。
|
|
244
|
-
|
|
245
|
-
this.
|
|
253
|
+
const now = Date.now()
|
|
254
|
+
this.lastRecvAt = now
|
|
255
|
+
this.lastTickAt = now
|
|
256
|
+
connectionMeta.openedAt = now
|
|
257
|
+
connectionMeta.lastRecvAt = 0
|
|
258
|
+
connectionMeta.lastTickAt = now
|
|
259
|
+
connectionMeta.lastTickGapMs = null
|
|
260
|
+
connectionMeta.lastHeartbeatAt = 0
|
|
261
|
+
connectionMeta.lastHeartbeatOk = null
|
|
262
|
+
connectionMeta.forceReconnectReason = null
|
|
263
|
+
connectionMeta.closeInitiator = null
|
|
246
264
|
// backoff の即時リセットはしない。接続が STABLE_CONNECTION_MS 維持 + inbound 受信できてから
|
|
247
265
|
// _armStableReset() でリセットする (open→即 close フラッピング時の再接続ストロボ対策)。
|
|
248
266
|
// すぐ切れた / half-open の接続では backoff がリセットされず指数バックオフが効き続けるため、
|
|
@@ -275,8 +293,8 @@ export class WsClient extends EventEmitter {
|
|
|
275
293
|
request_id: streamsSyncRequestId,
|
|
276
294
|
})
|
|
277
295
|
this._armStreamsSyncBarrier(streamsSyncRequestId)
|
|
278
|
-
this._startHeartbeat()
|
|
279
|
-
this._startBundleWatcher()
|
|
296
|
+
this._startHeartbeat(connectionMeta)
|
|
297
|
+
this._startBundleWatcher(connectionMeta)
|
|
280
298
|
this.emit("open")
|
|
281
299
|
}
|
|
282
300
|
|
|
@@ -419,6 +437,7 @@ export class WsClient extends EventEmitter {
|
|
|
419
437
|
}
|
|
420
438
|
if (this.ws) {
|
|
421
439
|
try {
|
|
440
|
+
this.connectionMeta.closeInitiator = "client_stop"
|
|
422
441
|
this.ws.close(1000, "client_stop")
|
|
423
442
|
} catch {
|
|
424
443
|
/* ignore */
|
|
@@ -430,6 +449,66 @@ export class WsClient extends EventEmitter {
|
|
|
430
449
|
// internals
|
|
431
450
|
// ------------------------------------------------------------
|
|
432
451
|
|
|
452
|
+
_newConnectionMeta(id) {
|
|
453
|
+
return {
|
|
454
|
+
id,
|
|
455
|
+
openedAt: 0,
|
|
456
|
+
lastRecvAt: 0,
|
|
457
|
+
lastTickAt: 0,
|
|
458
|
+
lastTickGapMs: null,
|
|
459
|
+
lastHeartbeatAt: 0,
|
|
460
|
+
lastHeartbeatOk: null,
|
|
461
|
+
forceReconnectReason: null,
|
|
462
|
+
closeInitiator: null,
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
_isActiveConnection(ws, connectionMeta) {
|
|
467
|
+
return this.ws === ws && this.connectionMeta === connectionMeta
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
_onSocketClose(ws, connectionMeta, code, reason) {
|
|
471
|
+
const now = Date.now()
|
|
472
|
+
const isActive = this._isActiveConnection(ws, connectionMeta)
|
|
473
|
+
this.logger?.info(
|
|
474
|
+
{
|
|
475
|
+
code,
|
|
476
|
+
reason: reason?.toString(),
|
|
477
|
+
connection_id: connectionMeta.id,
|
|
478
|
+
dur_s: connectionMeta.openedAt
|
|
479
|
+
? Math.max(0, Math.round((now - connectionMeta.openedAt) / 100) / 10)
|
|
480
|
+
: null,
|
|
481
|
+
since_recv_s: connectionMeta.lastRecvAt
|
|
482
|
+
? Math.max(0, Math.round((now - connectionMeta.lastRecvAt) / 100) / 10)
|
|
483
|
+
: null,
|
|
484
|
+
received_inbound: connectionMeta.lastRecvAt > 0,
|
|
485
|
+
last_heartbeat_at: connectionMeta.lastHeartbeatAt
|
|
486
|
+
? new Date(connectionMeta.lastHeartbeatAt).toISOString()
|
|
487
|
+
: null,
|
|
488
|
+
last_heartbeat_ok: connectionMeta.lastHeartbeatOk,
|
|
489
|
+
last_tick_at: connectionMeta.lastTickAt
|
|
490
|
+
? new Date(connectionMeta.lastTickAt).toISOString()
|
|
491
|
+
: null,
|
|
492
|
+
last_tick_gap_s: connectionMeta.lastTickGapMs == null
|
|
493
|
+
? null
|
|
494
|
+
: Math.max(0, Math.round(connectionMeta.lastTickGapMs / 100) / 10),
|
|
495
|
+
force_reconnect_reason: connectionMeta.forceReconnectReason,
|
|
496
|
+
close_initiator: connectionMeta.closeInitiator,
|
|
497
|
+
stale_generation: !isActive,
|
|
498
|
+
},
|
|
499
|
+
"ws close",
|
|
500
|
+
)
|
|
501
|
+
// terminate 後の close は次世代の connect/open より遅れて届くことがある。
|
|
502
|
+
// 旧世代から現世代の heartbeat・barrier・watcher・reconnect を変更しない。
|
|
503
|
+
if (!isActive) return
|
|
504
|
+
this._clearStreamsSyncBarrier()
|
|
505
|
+
this._clearStableReset()
|
|
506
|
+
this._stopHeartbeat()
|
|
507
|
+
this._stopBundleWatcher()
|
|
508
|
+
this.emit("close", { code, reason })
|
|
509
|
+
if (!this.stopped) this._scheduleReconnect()
|
|
510
|
+
}
|
|
511
|
+
|
|
433
512
|
_buildWsUrl() {
|
|
434
513
|
const hub = this.config.hub_url.replace(/\/+$/, "")
|
|
435
514
|
const scheme = hub.startsWith("https:") ? "wss:" : "ws:"
|
|
@@ -447,22 +526,39 @@ export class WsClient extends EventEmitter {
|
|
|
447
526
|
}
|
|
448
527
|
}
|
|
449
528
|
|
|
450
|
-
|
|
529
|
+
_sendHeartbeat(connectionMeta, heartbeatAt = Date.now()) {
|
|
530
|
+
if (connectionMeta !== this.connectionMeta) return null
|
|
531
|
+
const ok = this._sendJson({
|
|
532
|
+
type: "heartbeat",
|
|
533
|
+
uptime_sec: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
534
|
+
bundle_version: this.bundleVersion,
|
|
535
|
+
...this.bundleStatus,
|
|
536
|
+
})
|
|
537
|
+
connectionMeta.lastHeartbeatAt = heartbeatAt
|
|
538
|
+
connectionMeta.lastHeartbeatOk = ok
|
|
539
|
+
return ok
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
_startHeartbeat(connectionMeta = this.connectionMeta) {
|
|
451
543
|
this._stopHeartbeat()
|
|
452
544
|
this.lastTickAt = Date.now()
|
|
545
|
+
connectionMeta.lastTickAt = this.lastTickAt
|
|
453
546
|
this.heartbeatTimer = setInterval(() => {
|
|
547
|
+
if (connectionMeta !== this.connectionMeta) return
|
|
454
548
|
const now = Date.now()
|
|
455
549
|
// スリープ復帰検知: スリープ中はプロセスごと止まり、setInterval の実発火間隔が
|
|
456
550
|
// 大きく開く。復帰直後の TCP は half-open でほぼ確実に死んでいるのに send は
|
|
457
551
|
// 成功扱いになるため、鮮度判定 (受信側) を待たず即座に再接続へ倒す。
|
|
458
552
|
const tickGap = this.lastTickAt > 0 ? now - this.lastTickAt : 0
|
|
459
553
|
this.lastTickAt = now
|
|
554
|
+
connectionMeta.lastTickAt = now
|
|
555
|
+
connectionMeta.lastTickGapMs = tickGap
|
|
460
556
|
if (tickGap > TICK_JUMP_MS) {
|
|
461
557
|
this.logger?.warn(
|
|
462
558
|
{ tickGapMs: tickGap },
|
|
463
559
|
"heartbeat tick jump detected (likely sleep wake), forcing reconnect",
|
|
464
560
|
)
|
|
465
|
-
this._forceReconnect()
|
|
561
|
+
this._forceReconnect("sleep_tick_jump", connectionMeta)
|
|
466
562
|
return
|
|
467
563
|
}
|
|
468
564
|
// zombie WS 検知 (inbound 鮮度): backend は heartbeat のたびに ack を返すため、
|
|
@@ -475,7 +571,7 @@ export class WsClient extends EventEmitter {
|
|
|
475
571
|
{ sinceLastRecvMs: now - this.lastRecvAt },
|
|
476
572
|
"ws liveness timeout (zombie suspected), forcing reconnect",
|
|
477
573
|
)
|
|
478
|
-
this._forceReconnect()
|
|
574
|
+
this._forceReconnect("liveness_timeout", connectionMeta)
|
|
479
575
|
return
|
|
480
576
|
}
|
|
481
577
|
// heartbeat 都度 provider 経由で bundle version / CLI 別ステータスを最新化する。
|
|
@@ -485,15 +581,11 @@ export class WsClient extends EventEmitter {
|
|
|
485
581
|
// 送信失敗 (= ws not OPEN) を検知したら即時 close + reconnect 発火。
|
|
486
582
|
// setInterval を放置すると死んだコネクションに heartbeat を投げ続けて
|
|
487
583
|
// 30s 間気付かないので、close を強制トリガーする。
|
|
488
|
-
const ok = this.
|
|
489
|
-
|
|
490
|
-
uptime_sec: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
491
|
-
bundle_version: this.bundleVersion,
|
|
492
|
-
...this.bundleStatus,
|
|
493
|
-
})
|
|
584
|
+
const ok = this._sendHeartbeat(connectionMeta, now)
|
|
585
|
+
if (ok == null) return
|
|
494
586
|
if (!ok) {
|
|
495
587
|
this.logger?.warn("heartbeat send failed, forcing reconnect")
|
|
496
|
-
this._forceReconnect()
|
|
588
|
+
this._forceReconnect("heartbeat_send_failed", connectionMeta)
|
|
497
589
|
}
|
|
498
590
|
}, HEARTBEAT_INTERVAL_MS)
|
|
499
591
|
// unref して node プロセスが timer 1 個で抜けられなくならないように
|
|
@@ -549,13 +641,16 @@ export class WsClient extends EventEmitter {
|
|
|
549
641
|
* Cockpit UI への反映ラグを 30s heartbeat 周期から ~15s (UI poll) に短縮する。
|
|
550
642
|
* fs.watch の起動に失敗しても heartbeat 都度 refresh が保険として動くので no-op。
|
|
551
643
|
*/
|
|
552
|
-
_startBundleWatcher() {
|
|
553
|
-
if (!this.bundleManifestPath
|
|
644
|
+
_startBundleWatcher(connectionMeta = this.connectionMeta) {
|
|
645
|
+
if (!this.bundleManifestPath) return
|
|
646
|
+
if (this.bundleWatcher && this.bundleWatcherConnectionMeta === connectionMeta) return
|
|
647
|
+
if (this.bundleWatcher) this._stopBundleWatcher()
|
|
554
648
|
try {
|
|
649
|
+
this.bundleWatcherConnectionMeta = connectionMeta
|
|
555
650
|
this.bundleWatcher = fs.watch(
|
|
556
651
|
this.bundleManifestPath,
|
|
557
652
|
{ persistent: false },
|
|
558
|
-
() => this._onBundleManifestChange(),
|
|
653
|
+
() => this._onBundleManifestChange(connectionMeta),
|
|
559
654
|
)
|
|
560
655
|
this.bundleWatcher.on("error", (err) => {
|
|
561
656
|
this.logger?.warn(
|
|
@@ -568,6 +663,7 @@ export class WsClient extends EventEmitter {
|
|
|
568
663
|
"bundle manifest watcher started",
|
|
569
664
|
)
|
|
570
665
|
} catch (err) {
|
|
666
|
+
this.bundleWatcherConnectionMeta = null
|
|
571
667
|
this.logger?.warn(
|
|
572
668
|
{ err: err.message, path: this.bundleManifestPath },
|
|
573
669
|
"bundle manifest watcher failed to start",
|
|
@@ -588,43 +684,46 @@ export class WsClient extends EventEmitter {
|
|
|
588
684
|
}
|
|
589
685
|
this.bundleWatcher = null
|
|
590
686
|
}
|
|
687
|
+
this.bundleWatcherConnectionMeta = null
|
|
591
688
|
}
|
|
592
689
|
|
|
593
690
|
/**
|
|
594
691
|
* fs.watch のコールバック。同一書き込みで多重 fire することがある (macOS) ため
|
|
595
692
|
* デバウンスしてから refresh + 即時 heartbeat する。
|
|
596
693
|
*/
|
|
597
|
-
_onBundleManifestChange() {
|
|
694
|
+
_onBundleManifestChange(connectionMeta = this.connectionMeta) {
|
|
695
|
+
if (connectionMeta !== this.connectionMeta) return
|
|
598
696
|
if (this.bundleWatchDebounceTimer) {
|
|
599
697
|
clearTimeout(this.bundleWatchDebounceTimer)
|
|
600
698
|
}
|
|
601
699
|
this.bundleWatchDebounceTimer = setTimeout(() => {
|
|
602
700
|
this.bundleWatchDebounceTimer = null
|
|
701
|
+
if (connectionMeta !== this.connectionMeta) return
|
|
603
702
|
const changed = this._refreshBundleVersion()
|
|
604
703
|
if (!changed) return
|
|
605
704
|
this._refreshBundleStatus()
|
|
606
|
-
const ok = this.
|
|
607
|
-
|
|
608
|
-
uptime_sec: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
609
|
-
bundle_version: this.bundleVersion,
|
|
610
|
-
...this.bundleStatus,
|
|
611
|
-
})
|
|
705
|
+
const ok = this._sendHeartbeat(connectionMeta)
|
|
706
|
+
if (ok == null) return
|
|
612
707
|
if (!ok) {
|
|
613
708
|
this.logger?.warn("bundle-change heartbeat send failed, forcing reconnect")
|
|
614
|
-
this._forceReconnect()
|
|
709
|
+
this._forceReconnect("bundle_heartbeat_send_failed", connectionMeta)
|
|
615
710
|
}
|
|
616
711
|
}, BUNDLE_WATCH_DEBOUNCE_MS)
|
|
617
712
|
this.bundleWatchDebounceTimer.unref?.()
|
|
618
713
|
}
|
|
619
714
|
|
|
620
|
-
|
|
715
|
+
// reason の既定値は、将来の呼出漏れを無情報の null にせずログで検知する fail-soft。
|
|
716
|
+
_forceReconnect(reason = "unspecified", connectionMeta = this.connectionMeta) {
|
|
621
717
|
// 現在の ws を強制 close。close ハンドラが _scheduleReconnect を呼ぶので
|
|
622
718
|
// それ以上の処理は不要。stopped 中は no-op。
|
|
623
719
|
// ws 実装によって terminate (ws npm package) と close (browser WebSocket
|
|
624
720
|
// 互換) どちらが生えているか異なるので両対応する。
|
|
721
|
+
if (connectionMeta !== this.connectionMeta) return
|
|
625
722
|
if (this.stopped) return
|
|
626
723
|
if (!this.ws) return
|
|
627
724
|
if (this.ws.readyState === WebSocket.CLOSED) return
|
|
725
|
+
connectionMeta.forceReconnectReason = reason
|
|
726
|
+
connectionMeta.closeInitiator = "agent_reconnect"
|
|
628
727
|
try {
|
|
629
728
|
if (typeof this.ws.terminate === "function") {
|
|
630
729
|
this.ws.terminate()
|
|
@@ -682,7 +781,7 @@ export class WsClient extends EventEmitter {
|
|
|
682
781
|
{ sinceArmedMs: Date.now() - armedAt },
|
|
683
782
|
"ws stable window elapsed with no inbound (half-open suspected), forcing reconnect",
|
|
684
783
|
)
|
|
685
|
-
this._forceReconnect()
|
|
784
|
+
this._forceReconnect("stable_no_inbound")
|
|
686
785
|
}
|
|
687
786
|
}, STABLE_CONNECTION_MS)
|
|
688
787
|
this.stableResetTimer.unref?.()
|