@cocorograph/hub-agent 0.7.14 → 0.7.15

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": "@cocorograph/hub-agent",
3
- "version": "0.7.14",
3
+ "version": "0.7.15",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/main.mjs CHANGED
@@ -1672,9 +1672,13 @@ export async function handleTuiInterrupt(msg, ctx) {
1672
1672
  const sendEsc = ctx.sendInterruptKey || sendInterruptKey
1673
1673
  const detect = ctx.detectSessionState || detectSessionState
1674
1674
  const delay = ctx.delay || ((ms) => new Promise((r) => setTimeout(r, ms)))
1675
- const settleMs = ctx.interruptSettleMs ?? 350
1676
- const pollMs = ctx.interruptPollMs ?? 350
1677
- const maxPolls = ctx.interruptMaxPolls ?? 7 // ~2.5s deadline
1675
+ // 症状⑤: 初回チェックまでの settle poll 間隔を 350→150ms に詰め、maxPolls を 7→16 に
1676
+ // 増やす。総 deadline は ~2.4s で従来(~2.5s)とほぼ同じだが、実際に停止した瞬間の観測が
1677
+ // 2倍速くなり「停止押下→画面が止まる」体感ラグを縮める。capture-pane スクレイプ回数は
1678
+ // 増えるが 150ms 間隔なら許容範囲。Ctrl+C 非エスカレーション方針は不変。
1679
+ const settleMs = ctx.interruptSettleMs ?? 150
1680
+ const pollMs = ctx.interruptPollMs ?? 150
1681
+ const maxPolls = ctx.interruptMaxPolls ?? 16 // ~2.4s deadline (150 + 150*15)
1678
1682
  const reply = (stopped, attempts) =>
1679
1683
  ctx.client.send({
1680
1684
  type: "claude.tui.interrupt.result",
package/src/ws-client.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * - 接続: `Authorization: Bearer <agent_id>:<agent_token>`
5
5
  * - 起動時に `hello`、30s おきに `heartbeat` を送信
6
- * - 切断時は exponential backoff (1s, 2s, 4s, ..., max 30s) で再接続
6
+ * - 切断時は exponential backoff (1s, 2s, 4s, 8s max) で再接続
7
7
  * - サーバから受け取った JSON は `onMessage` callback に渡す
8
8
  * - `bundleVersionProvider` + `bundleManifestPath` を渡すと、manifest.json の
9
9
  * 変更を fs.watch で検知して即時 heartbeat を送信し Cockpit UI に最新版を
@@ -20,7 +20,10 @@ import WebSocket from "ws"
20
20
 
21
21
  const HEARTBEAT_INTERVAL_MS = 30_000
22
22
  const MIN_BACKOFF_MS = 1_000
23
- const MAX_BACKOFF_MS = 30_000
23
+ // 症状①(接続復帰の遅さ)対策: 旧 30s。本番ログで再接続 p50=1.9s に対し、フラッピング時に
24
+ // backoff が指数増加して復帰レイテンシのテール(p90=9s, 最大35s)を作っていた。実測の切断は
25
+ // ほぼ瞬断(CF/NW)で 8s も待てば回復に十分。jitter(±20%)で thundering herd は引き続き回避。
26
+ const MAX_BACKOFF_MS = 8_000
24
27
  const BUNDLE_WATCH_DEBOUNCE_MS = 500
25
28
 
26
29
  // zombie WS (half-open TCP) 検知のしきい値。backend は heartbeat (30s) のたびに
@@ -196,15 +199,18 @@ export class WsClient extends EventEmitter {
196
199
  * のクロージャだと spy しづらい)。
197
200
  */
198
201
  _onOpen() {
199
- // backoff の即時リセットはしない。接続が STABLE_CONNECTION_MS 維持できてから
200
- // _armStableReset() でリセットする (open→即 close フラッピング時の再接続
201
- // ストロボ対策)。すぐ切れた接続では backoff がリセットされず指数バックオフが
202
- // 効き続けるため、1〜2 秒間隔の再接続ループに陥らない。
203
- this._armStableReset()
204
202
  // open 自体が生存の証拠。直前接続の古い lastRecvAt で開幕直後に zombie 誤検知
205
- // しないようリセットする。
203
+ // しないようリセットする。⚠️ _armStableReset() の armedAt 捕捉より「前」に設定すること。
204
+ // ack ベース判定 (lastRecvAt > armedAt) を初期状態で偽にするため (順序を逆にすると
205
+ // armedAt < lastRecvAt となり「inbound 受信済み」と誤判定し、half-open でも backoff を
206
+ // リセットしてしまう)。
206
207
  this.lastRecvAt = Date.now()
207
208
  this.lastTickAt = Date.now()
209
+ // backoff の即時リセットはしない。接続が STABLE_CONNECTION_MS 維持 + inbound 受信できてから
210
+ // _armStableReset() でリセットする (open→即 close フラッピング時の再接続ストロボ対策)。
211
+ // すぐ切れた / half-open の接続では backoff がリセットされず指数バックオフが効き続けるため、
212
+ // 1〜2 秒間隔の再接続ループに陥らない。
213
+ this._armStableReset()
208
214
  this.logger?.info("ws open")
209
215
  // 切断中に積んだ pty.data を flush。hello より先に送ると backend で stream
210
216
  // 未登録のため unknown_stream error になる可能性があるが、hello の応答待ち
@@ -583,11 +589,27 @@ export class WsClient extends EventEmitter {
583
589
  */
584
590
  _armStableReset() {
585
591
  this._clearStableReset()
592
+ // 症状①: backoff リセットを「ack ベース」にする。armedAt(≈open 時刻) を捕捉し、
593
+ // STABLE_CONNECTION_MS 後に「open 以降に inbound を受信したか(lastRecvAt > armedAt)」で
594
+ // 判定する。受信あり = 双方向に健全とみなし backoff/5xx をリセット。受信ゼロ =
595
+ // half-open(send は kernel buffer に書けて成功扱いだが TCP は死)とみなし、75s の zombie
596
+ // 検知を待たずに即 forceReconnect する(再接続後の half-open を 10s で回収し復帰を早める)。
597
+ // _onOpen は lastRecvAt=now を armedAt より前に設定するため、初期状態では
598
+ // lastRecvAt <= armedAt となり「未受信」と正しく判定される。
599
+ const armedAt = Date.now()
586
600
  this.stableResetTimer = setTimeout(() => {
587
601
  this.stableResetTimer = null
588
- this.backoff = MIN_BACKOFF_MS
589
- this.lastCloseWas5xx = false
590
- this.logger?.debug("ws connection stable, backoff reset")
602
+ if (this.lastRecvAt > armedAt) {
603
+ this.backoff = MIN_BACKOFF_MS
604
+ this.lastCloseWas5xx = false
605
+ this.logger?.debug("ws connection stable (inbound received), backoff reset")
606
+ } else {
607
+ this.logger?.warn(
608
+ { sinceArmedMs: Date.now() - armedAt },
609
+ "ws stable window elapsed with no inbound (half-open suspected), forcing reconnect",
610
+ )
611
+ this._forceReconnect()
612
+ }
591
613
  }, STABLE_CONNECTION_MS)
592
614
  this.stableResetTimer.unref?.()
593
615
  }