@cocorograph/hub-agent 0.5.31 → 0.5.32

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/ws-client.mjs +88 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.5.31",
3
+ "version": "0.5.32",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/ws-client.mjs CHANGED
@@ -23,6 +23,22 @@ const MIN_BACKOFF_MS = 1_000
23
23
  const MAX_BACKOFF_MS = 30_000
24
24
  const BUNDLE_WATCH_DEBOUNCE_MS = 500
25
25
 
26
+ // CF / origin が 5xx を返した直後の再接続は短い backoff だと 5xx キャッシュに
27
+ // 当たって連発失敗するため、最低 5s 待つ。30s リトライまで段階的に伸びる。
28
+ const MIN_BACKOFF_AFTER_5XX_MS = 5_000
29
+
30
+ // outbound pty.data buffer のサイズ上限。
31
+ // WS not OPEN 中に pty.data を捨てると Cockpit 上のターミナル描画が欠落して
32
+ // 「動かない / 一部だけ表示される」体感を生むため、reconnect 後に flush する。
33
+ // pty.data は冪等で順序維持できれば xterm 側で正しく再現できる。
34
+ // 1 frame は典型的に 数十〜数百 bytes (claude TUI の partial redraw)。
35
+ // 500 frame ≒ 数十KB を 1 切断あたりの上限とする。
36
+ const PTY_BUFFER_MAX_FRAMES = 500
37
+ // outbound buffer に残った pty.data が古すぎると、ターミナルの履歴として
38
+ // 再生する意味が薄れる (ユーザーが視覚的に「過去のもの」として無視する範囲)。
39
+ // 30 秒以上経過した pty.data は flush 時に破棄する。
40
+ const PTY_BUFFER_MAX_AGE_MS = 30_000
41
+
26
42
  export class WsClient extends EventEmitter {
27
43
  /**
28
44
  * @param {{ hub_url: string, agent_id: string, agent_token: string }} config
@@ -55,6 +71,11 @@ export class WsClient extends EventEmitter {
55
71
  this.backoff = MIN_BACKOFF_MS
56
72
  this.stopped = false
57
73
  this.startedAt = Date.now()
74
+ // pty.data の outbound buffer (WS not OPEN 中に積み、open 後 flush)。
75
+ // entry: { obj, ts }
76
+ this.ptyOutboundBuffer = []
77
+ // 直前の close 原因が CF/origin の 5xx か。次回 backoff の下限を引き上げる。
78
+ this.lastCloseWas5xx = false
58
79
  }
59
80
 
60
81
  /** WSS 接続を開始する。`stop()` まで自動で reconnect 続行。 */
@@ -92,6 +113,12 @@ export class WsClient extends EventEmitter {
92
113
 
93
114
  ws.on("error", (err) => {
94
115
  this.logger?.warn({ err: err.message }, "ws error")
116
+ // Upgrade で CF/origin が 5xx を返した場合 (典型: "Unexpected server
117
+ // response: 502") は、次回 reconnect の最低 backoff を引き上げる。
118
+ // CF が 5xx を short-cache するため、1s 連発リトライは逆に詰まる。
119
+ if (/Unexpected server response: 5\d\d/.test(err?.message || "")) {
120
+ this.lastCloseWas5xx = true
121
+ }
95
122
  this.emit("error", err)
96
123
  // close もほぼ続けて飛ぶので reconnect 予約は close 側に任せる
97
124
  })
@@ -110,7 +137,13 @@ export class WsClient extends EventEmitter {
110
137
  */
111
138
  _onOpen() {
112
139
  this.backoff = MIN_BACKOFF_MS
140
+ this.lastCloseWas5xx = false
113
141
  this.logger?.info("ws open")
142
+ // 切断中に積んだ pty.data を flush。hello より先に送ると backend で stream
143
+ // 未登録のため unknown_stream error になる可能性があるが、hello の応答待ち
144
+ // 同期は backend が承認するため hello 送信後の同期 send_json で問題ない。
145
+ // ただし flush は hello 直後ではなく streams.sync.request の後に置く方が
146
+ // 安全 (sync 完了で stream_id がサーバに復元される想定)。
114
147
  // hello 前に最新の bundle version を読み直す (再接続時の鮮度確保)
115
148
  this._refreshBundleVersion()
116
149
  this._sendJson({
@@ -129,20 +162,65 @@ export class WsClient extends EventEmitter {
129
162
  type: "agent.streams.sync.request",
130
163
  request_id: randomUUID(),
131
164
  })
165
+ this._flushPtyBuffer()
132
166
  this._startHeartbeat()
133
167
  this._startBundleWatcher()
134
168
  this.emit("open")
135
169
  }
136
170
 
137
- /** メッセージを送る。未接続なら no-op (logger.warn) */
171
+ /** メッセージを送る。未接続時は pty.data だけ buffer に積み、reconnect 後に flush
172
+ *
173
+ * heartbeat / hello / agent.streams.sync.* など制御系は古くなると意味が無いので
174
+ * buffer しない (warn のみ)。
175
+ */
138
176
  send(obj) {
139
177
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
178
+ if (obj?.type === "pty.data") {
179
+ this._bufferPtyData(obj)
180
+ return false
181
+ }
140
182
  this.logger?.warn({ type: obj?.type }, "ws send skipped (not open)")
141
183
  return false
142
184
  }
143
185
  return this._sendJson(obj)
144
186
  }
145
187
 
188
+ /** pty.data を outbound buffer に積む。リング (drop oldest on overflow)。 */
189
+ _bufferPtyData(obj) {
190
+ this.ptyOutboundBuffer.push({ obj, ts: Date.now() })
191
+ if (this.ptyOutboundBuffer.length > PTY_BUFFER_MAX_FRAMES) {
192
+ const dropped = this.ptyOutboundBuffer.length - PTY_BUFFER_MAX_FRAMES
193
+ this.ptyOutboundBuffer.splice(0, dropped)
194
+ this.logger?.warn(
195
+ { dropped, kept: this.ptyOutboundBuffer.length },
196
+ "pty outbound buffer overflow (oldest dropped)"
197
+ )
198
+ }
199
+ }
200
+
201
+ /** open 直後に buffer を flush。古すぎる entry は破棄する。 */
202
+ _flushPtyBuffer() {
203
+ if (this.ptyOutboundBuffer.length === 0) return
204
+ const now = Date.now()
205
+ const buf = this.ptyOutboundBuffer
206
+ this.ptyOutboundBuffer = []
207
+ let sent = 0
208
+ let expired = 0
209
+ for (const entry of buf) {
210
+ if (now - entry.ts > PTY_BUFFER_MAX_AGE_MS) {
211
+ expired += 1
212
+ continue
213
+ }
214
+ const ok = this._sendJson(entry.obj)
215
+ if (!ok) break
216
+ sent += 1
217
+ }
218
+ this.logger?.info(
219
+ { sent, expired, total: buf.length },
220
+ "pty outbound buffer flushed"
221
+ )
222
+ }
223
+
146
224
  /** Reconnect を止めて切断する。 */
147
225
  stop() {
148
226
  this.stopped = true
@@ -326,11 +404,16 @@ export class WsClient extends EventEmitter {
326
404
 
327
405
  _scheduleReconnect() {
328
406
  // exponential backoff + ±20% jitter で同時接続が同期しないようにする。
329
- const base = this.backoff
407
+ // 直前が 5xx だった場合は CF キャッシュ回避のため最低 5s を確保する。
408
+ const minFloor = this.lastCloseWas5xx ? MIN_BACKOFF_AFTER_5XX_MS : MIN_BACKOFF_MS
409
+ const base = Math.max(this.backoff, minFloor)
330
410
  const jitter = base * 0.2 * (Math.random() * 2 - 1)
331
- const delay = Math.max(MIN_BACKOFF_MS, Math.round(base + jitter))
332
- this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS)
333
- this.logger?.info({ delayMs: delay, nextBaseMs: this.backoff }, "ws reconnect scheduled")
411
+ const delay = Math.max(minFloor, Math.round(base + jitter))
412
+ this.backoff = Math.min(Math.max(this.backoff, minFloor) * 2, MAX_BACKOFF_MS)
413
+ this.logger?.info(
414
+ { delayMs: delay, nextBaseMs: this.backoff, after5xx: this.lastCloseWas5xx },
415
+ "ws reconnect scheduled"
416
+ )
334
417
  this.reconnectTimer = setTimeout(() => {
335
418
  this.reconnectTimer = null
336
419
  this.connect()