@cocorograph/hub-agent 0.8.45 → 0.8.46

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.8.45",
3
+ "version": "0.8.46",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/usage.mjs CHANGED
@@ -1482,6 +1482,41 @@ function threadIdFromRolloutFilename(fp) {
1482
1482
  const CODEX_ROLLOUT_HEAD_CHUNK_BYTES = 32 * 1024
1483
1483
  const CODEX_ROLLOUT_PREVIEW_MAX_BYTES = 1024 * 1024
1484
1484
 
1485
+ // sessionsDir ごとの派生インデックス。rollout 自体が正本なので永続化は不要。
1486
+ // _codexTokenCountMemo と同じ mtime+size 無効化規約で、変更ファイルだけを再解析する。
1487
+ // 共通 helper 化は usage 集計側への回帰範囲を広げるため、一覧用 index として独立させる。
1488
+ const _codexThreadListIndex = new Map() // base → Map<fp, {mtimeMs,size,lastSeenAt,meta}>
1489
+ const _codexThreadListLastWarnAt = new Map() // base → wall-clock ms
1490
+ const CODEX_THREAD_LIST_LOG_INTERVAL_MS = 5 * 60 * 1000
1491
+ const CODEX_THREAD_LIST_STALE_ENTRY_MS = 24 * 60 * 60 * 1000
1492
+ const CODEX_ROLLOUT_PARSE_IO_ERROR = Symbol("codex-rollout-parse-io-error")
1493
+
1494
+ function sameCodexThreadListFingerprint(a, b) {
1495
+ return a?.mtimeMs === b?.mtimeMs && a?.size === b?.size
1496
+ }
1497
+
1498
+ function codexThreadListMetaRank(meta) {
1499
+ if (meta?.preview_complete && meta.cwd && meta.preview) return 3
1500
+ if (meta?.preview_complete) return 2
1501
+ return meta ? 1 : 0
1502
+ }
1503
+
1504
+ // observed 後に別呼び出しが新 fingerprint / complete meta を置いた場合は降格させない。
1505
+ function commitCodexThreadListEntry(index, fp, observed, next) {
1506
+ const current = index.get(fp)
1507
+ if (current === observed) {
1508
+ index.set(fp, next)
1509
+ return
1510
+ }
1511
+ if (!current || !sameCodexThreadListFingerprint(current, next)) return
1512
+ const meta =
1513
+ codexThreadListMetaRank(next.meta) > codexThreadListMetaRank(current.meta)
1514
+ ? next.meta
1515
+ : current.meta
1516
+ const merged = { ...current, lastSeenAt: Math.max(current.lastSeenAt, next.lastSeenAt), meta }
1517
+ index.set(fp, merged)
1518
+ }
1519
+
1485
1520
  /**
1486
1521
  * rollout の先頭から一覧用メタ (thread_id / cwd / preview) を取り出す。
1487
1522
  *
@@ -1492,9 +1527,9 @@ const CODEX_ROLLOUT_PREVIEW_MAX_BYTES = 1024 * 1024
1492
1527
  *
1493
1528
  * @param {string} fp
1494
1529
  * @param {string} expectedCwd cwd-scoped 一覧の対象。別 cwd は session_meta 読み取り後に早期終了。
1495
- * @returns {Promise<{thread_id:string,cwd:string,preview:string}|null>}
1530
+ * @returns {Promise<{thread_id:string,cwd:string,preview:string,preview_complete:boolean}|null|symbol>}
1496
1531
  */
1497
- async function parseCodexRolloutHead(fp, expectedCwd = "") {
1532
+ async function parseCodexRolloutHead(fp, expectedCwd = "", testHooks = null) {
1498
1533
  let thread_id = threadIdFromRolloutFilename(fp)
1499
1534
  let cwd = ""
1500
1535
  let preview = ""
@@ -1502,6 +1537,7 @@ async function parseCodexRolloutHead(fp, expectedCwd = "") {
1502
1537
  const decoder = new StringDecoder("utf8")
1503
1538
  let pending = ""
1504
1539
  let offset = 0
1540
+ let result
1505
1541
 
1506
1542
  const parseLine = (line) => {
1507
1543
  if (!line) return
@@ -1528,8 +1564,13 @@ async function parseCodexRolloutHead(fp, expectedCwd = "") {
1528
1564
  }
1529
1565
 
1530
1566
  try {
1567
+ if (testHooks?.beforeHeaderOpen) await testHooks.beforeHeaderOpen(fp)
1568
+ testHooks?.onHeaderOpen?.(fp)
1569
+ if (testHooks?.failHeaderOpen?.(fp)) {
1570
+ throw Object.assign(new Error("injected header open failure"), { code: "EIO" })
1571
+ }
1531
1572
  fh = await fs.open(fp, "r")
1532
- while (offset < CODEX_ROLLOUT_PREVIEW_MAX_BYTES) {
1573
+ scan: while (offset < CODEX_ROLLOUT_PREVIEW_MAX_BYTES) {
1533
1574
  const readSize = Math.min(
1534
1575
  CODEX_ROLLOUT_HEAD_CHUNK_BYTES,
1535
1576
  CODEX_ROLLOUT_PREVIEW_MAX_BYTES - offset,
@@ -1550,12 +1591,14 @@ async function parseCodexRolloutHead(fp, expectedCwd = "") {
1550
1591
  pending = pending.slice(newline + 1)
1551
1592
  parseLine(line)
1552
1593
  // session_meta は先頭行にある。別 workspace の候補に対して巨大な初期コンテキストを
1553
- // 読み進めないことで、maxScan が大きい場合も一覧走査コストを抑える。
1594
+ // 読み進めないことで、初回の派生インデックス構築コストを抑える。
1554
1595
  if (cwd && expectedCwd && cwd !== expectedCwd) {
1555
- return thread_id ? { thread_id, cwd, preview } : null
1596
+ result = thread_id ? { thread_id, cwd, preview, preview_complete: false } : null
1597
+ break scan
1556
1598
  }
1557
1599
  if (cwd && preview) {
1558
- return thread_id ? { thread_id, cwd, preview } : null
1600
+ result = thread_id ? { thread_id, cwd, preview, preview_complete: true } : null
1601
+ break scan
1559
1602
  }
1560
1603
  }
1561
1604
 
@@ -1567,8 +1610,11 @@ async function parseCodexRolloutHead(fp, expectedCwd = "") {
1567
1610
  break
1568
1611
  }
1569
1612
  }
1613
+ if (result === undefined) {
1614
+ result = thread_id ? { thread_id, cwd, preview, preview_complete: true } : null
1615
+ }
1570
1616
  } catch {
1571
- return null
1617
+ result = CODEX_ROLLOUT_PARSE_IO_ERROR
1572
1618
  } finally {
1573
1619
  try {
1574
1620
  await fh?.close()
@@ -1577,20 +1623,22 @@ async function parseCodexRolloutHead(fp, expectedCwd = "") {
1577
1623
  }
1578
1624
  }
1579
1625
 
1580
- if (!thread_id) return null
1581
- return { thread_id, cwd, preview }
1626
+ if (result !== CODEX_ROLLOUT_PARSE_IO_ERROR && testHooks?.afterHeaderParse) {
1627
+ await testHooks.afterHeaderParse(fp, result)
1628
+ }
1629
+ return result
1582
1630
  }
1583
1631
 
1584
1632
  /**
1585
1633
  * cwd 配下の Codex thread 一覧を rollout の disk 走査だけで作る (app-server を spawn しない)。
1586
1634
  * 直近 lookbackDays の日付シャードから rollout-*.jsonl を集め、mtime 降順に並べ、header を
1587
- * 読んで cwd 一致分を limit 件まで返す。返り値の shape は旧 listThreads と同一:
1635
+ * 派生インデックスを更新してから cwd 一致分を limit 件まで返す。返り値の shape は旧 listThreads と同一:
1588
1636
  * {thread_id, cwd, preview, updated_at_ms}[]
1589
1637
  *
1590
1638
  * ⚠️ rollout は「作成日」シャードに置かれ以後追記される。作成が lookbackDays より前で今日
1591
1639
  * resume された長寿命スレッドは窓外で拾えない (findCodexRolloutFile の高速パスと同じ限界)。
1592
- * 一覧 (picker) 用途では直近が支配的なので許容。header 読み取り件数は maxScan で上限を掛け、
1593
- * 打ち切ったら logger.warn で可視化する (silent truncation を避ける)。
1640
+ * 一覧 (picker) 用途では直近が支配的なので許容。maxScan は既存 API 互換のため受け取っても
1641
+ * 無視し、cwd ごとの一覧欠落を起こすグローバル走査上限には使わない。
1594
1642
  *
1595
1643
  * @returns {Promise<Array<{thread_id:string, cwd:string, preview:string, updated_at_ms:number}>>}
1596
1644
  */
@@ -1598,10 +1646,11 @@ export async function listCodexThreadsFromDisk({
1598
1646
  cwd,
1599
1647
  limit,
1600
1648
  lookbackDays = 180,
1601
- maxScan = 800,
1602
1649
  now = Date.now(),
1603
1650
  logger,
1604
1651
  sessionsDir,
1652
+ // テスト専用。production 呼び出しでは指定しない。
1653
+ _testHooks,
1605
1654
  } = {}) {
1606
1655
  // cwd-scoped picker 用。cwd 未指定なら「全 workspace を返す」ではなく空を返す (cross-review
1607
1656
  // major3: 空 cwd で全 workspace の rollout が混入するのを防ぐ。呼び出し側 frontend は常に cwd を
@@ -1610,13 +1659,16 @@ export async function listCodexThreadsFromDisk({
1610
1659
  // sessionsDir は self-describing read (Read-plane 案C ①) で呼び出し側が
1611
1660
  // 「リクエストの profile_id から解決した読み取り先」を明示注入する経路。
1612
1661
  // 未指定なら従来どおり env 由来の active (codexSessionsDir) にフォールバックする。
1613
- const base = sessionsDir || codexSessionsDir()
1614
- // 1. lookback 窓内の全 rollout を stat して {fp, mtimeMs} を集める。
1662
+ const base = path.resolve(sessionsDir || codexSessionsDir())
1663
+ const seenAt = _testHooks?.nowMs?.() ?? Date.now()
1664
+ // 1. lookback 窓内の全 rollout を stat して {fp, mtimeMs, size} を集める。
1615
1665
  // ⚠️ updated_at_ms (= mtime) 降順の契約を守るには「どれが更新新しいか」を知る必要があり、
1616
1666
  // それには窓内の全 stat が要る (cross-review round2: 収集を作成日 shard 順で打ち切ると、
1617
1667
  // 作成が古く更新が新しい長寿命 rollout を落として mtime 降順に反する)。stat は 1 syscall で
1618
- // 安価かつ lookbackDays で有界。高コストな header open+read の方を maxScan で上限化する。
1619
- const candidates = [] // {fp, mtimeMs}
1668
+ // 安価かつ lookbackDays で有界。header open+read は下の派生インデックスで変更分だけに抑える。
1669
+ const candidates = [] // {fp, mtimeMs, size}
1670
+ const discovered = new Set() // readdir で存在を確認できた fp (stat 一時失敗でも削除扱いにしない)
1671
+ const scannedDirs = new Set()
1620
1672
  for (let i = 0; i < lookbackDays; i++) {
1621
1673
  const d = new Date(now - i * 24 * 60 * 60 * 1000)
1622
1674
  const y = String(d.getFullYear())
@@ -1625,27 +1677,97 @@ export async function listCodexThreadsFromDisk({
1625
1677
  const dir = path.join(base, y, m, day)
1626
1678
  const files = await fs.readdir(dir).catch(() => null)
1627
1679
  if (!files) continue
1680
+ scannedDirs.add(dir)
1628
1681
  for (const f of files) {
1629
1682
  if (!f.startsWith("rollout-") || !f.endsWith(".jsonl")) continue
1630
1683
  const fp = path.join(dir, f)
1684
+ discovered.add(fp)
1631
1685
  const st = await fs.stat(fp).catch(() => null)
1632
- if (st) candidates.push({ fp, mtimeMs: st.mtimeMs })
1686
+ if (st) candidates.push({ fp, mtimeMs: st.mtimeMs, size: st.size })
1633
1687
  }
1634
1688
  }
1635
- // 2. 更新が新しい順 (mtime desc)。
1689
+ // 2. 更新が新しい順 (mtime desc)。候補一覧自体は毎回取り直し、削除も検出する。
1636
1690
  candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
1637
- // 3. mtime 新しい順に header を読み、cwd 一致分を limit まで集める。header 読み (open+read) は
1638
- // maxScan で上限化する。上限打ち切りは mtime 新しい側から読むので、直近のスレッドが優先的に残る。
1691
+ let index = _codexThreadListIndex.get(base)
1692
+ if (!index) {
1693
+ index = new Map()
1694
+ _codexThreadListIndex.set(base, index)
1695
+ }
1696
+ for (const fp of index.keys()) {
1697
+ // 今回実際に走査できた shard 内の不在だけを削除扱いにする。短い lookback や別 now の
1698
+ // 呼び出しが、走査範囲外の cache を消して互いに再openを誘発しないようにする。
1699
+ if (scannedDirs.has(path.dirname(fp)) && !discovered.has(fp)) {
1700
+ index.delete(fp)
1701
+ _testHooks?.onIndexPrune?.(fp)
1702
+ } else {
1703
+ const entry = index.get(fp)
1704
+ if (!discovered.has(fp) && seenAt - entry.lastSeenAt >= CODEX_THREAD_LIST_STALE_ENTRY_MS) {
1705
+ index.delete(fp)
1706
+ _testHooks?.onIndexPrune?.(fp)
1707
+ }
1708
+ }
1709
+ }
1710
+
1711
+ // 3. 呼び出しローカル snapshot を候補取得時点の cache から作る。以後、並行呼び出しが
1712
+ // global index を prune / 更新しても、この呼び出しの出力は snapshot から安定して作れる。
1713
+ const snapshot = new Map()
1714
+ for (const { fp, mtimeMs, size } of candidates) {
1715
+ const cached = index.get(fp)
1716
+ if (cached && cached.mtimeMs === mtimeMs && cached.size === size) {
1717
+ const seen = { ...cached, lastSeenAt: seenAt }
1718
+ commitCodexThreadListEntry(index, fp, cached, seen)
1719
+ // snapshot はこの呼び出しが観測した候補を正本にし、並行書戻しの成否に依存させない。
1720
+ snapshot.set(fp, seen)
1721
+ } else if (
1722
+ cached?.meta?.preview_complete &&
1723
+ cached.meta.cwd &&
1724
+ cached.meta.preview &&
1725
+ size > cached.size &&
1726
+ mtimeMs >= cached.mtimeMs
1727
+ ) {
1728
+ // rollout は append-only。preview まで確定済みなら末尾追記で header は変わらないため、
1729
+ // fingerprint だけ進める。truncate / mtime 巻戻し / preview 未完了は下で再解析する。
1730
+ const appended = { mtimeMs, size, lastSeenAt: seenAt, meta: cached.meta }
1731
+ commitCodexThreadListEntry(index, fp, cached, appended)
1732
+ snapshot.set(fp, appended)
1733
+ }
1734
+ }
1735
+
1736
+ // 4. 新規・変更分だけ header を解析する。別 cwd は session_meta を得た時点で止め、
1737
+ // その cwd が後で要求された時に一度だけ preview まで補完する。一時 I/O 失敗は cache せず、
1738
+ // 次 poll で再試行する。解析済み不正 header (meta=null) は fingerprint 不変なら再解析しない。
1739
+ let parsedHeaders = 0
1740
+ const headerParseStartedAt = Date.now()
1741
+ for (const { fp, mtimeMs, size } of candidates) {
1742
+ if (snapshot.has(fp)) continue
1743
+ const observed = index.get(fp)
1744
+ parsedHeaders++
1745
+ const meta = await parseCodexRolloutHead(fp, cwd, _testHooks)
1746
+ if (meta === CODEX_ROLLOUT_PARSE_IO_ERROR) continue
1747
+ const entry = { mtimeMs, size, lastSeenAt: seenAt, meta }
1748
+ commitCodexThreadListEntry(index, fp, observed, entry)
1749
+ snapshot.set(fp, entry)
1750
+ }
1751
+
1752
+ // 5. cwd で絞ってから mtime 降順・limit を適用する。グローバル候補数には左右されない。
1639
1753
  const out = []
1640
- let opened = 0
1641
- for (const { fp, mtimeMs } of candidates) {
1754
+ let completedPreviews = 0
1755
+ for (const { fp, mtimeMs, size } of candidates) {
1642
1756
  if (limit && out.length >= limit) break
1643
- if (opened >= maxScan) break
1644
- opened++
1645
- const meta = await parseCodexRolloutHead(fp, cwd)
1757
+ const entry = snapshot.get(fp)
1758
+ let meta = entry?.meta
1646
1759
  if (!meta) continue
1647
- // header の cwd が一致するものだけ (cwd 未読/不一致は除外 = 無関係スレッドの混入防止)。
1648
1760
  if (meta.cwd !== cwd) continue
1761
+ if (!meta.preview_complete) {
1762
+ const observed = index.get(fp)
1763
+ completedPreviews++
1764
+ meta = await parseCodexRolloutHead(fp, "", _testHooks)
1765
+ if (meta === CODEX_ROLLOUT_PARSE_IO_ERROR) continue
1766
+ const completeEntry = { mtimeMs, size, lastSeenAt: seenAt, meta }
1767
+ commitCodexThreadListEntry(index, fp, observed, completeEntry)
1768
+ snapshot.set(fp, completeEntry)
1769
+ if (!meta || meta.cwd !== cwd) continue
1770
+ }
1649
1771
  out.push({
1650
1772
  thread_id: meta.thread_id,
1651
1773
  cwd: meta.cwd,
@@ -1653,12 +1775,28 @@ export async function listCodexThreadsFromDisk({
1653
1775
  updated_at_ms: mtimeMs,
1654
1776
  })
1655
1777
  }
1656
- if (opened >= maxScan && (!limit || out.length < limit)) {
1657
- logger?.warn?.(
1658
- { opened, total: candidates.length, cwd, matched: out.length },
1659
- "listCodexThreadsFromDisk: maxScan で header 走査を打ち切り (mtime が更に古い側の一部が一覧に出ない可能性)",
1778
+
1779
+ const elapsedMs = Date.now() - headerParseStartedAt
1780
+ const wallNow = Date.now()
1781
+ const lastWarnAt = _codexThreadListLastWarnAt.get(base)
1782
+ if (
1783
+ typeof logger?.warn === "function" &&
1784
+ (parsedHeaders + completedPreviews > 200 || elapsedMs > 1000) &&
1785
+ (lastWarnAt === undefined || wallNow - lastWarnAt >= CODEX_THREAD_LIST_LOG_INTERVAL_MS)
1786
+ ) {
1787
+ _codexThreadListLastWarnAt.set(base, wallNow)
1788
+ logger.warn(
1789
+ {
1790
+ cwd,
1791
+ parsed_headers: parsedHeaders,
1792
+ completed_previews: completedPreviews,
1793
+ total_candidates: candidates.length,
1794
+ elapsed_ms: elapsedMs,
1795
+ },
1796
+ "listCodexThreadsFromDisk: 派生インデックスの header 解析負荷が閾値超過",
1660
1797
  )
1661
1798
  }
1799
+
1662
1800
  return out
1663
1801
  }
1664
1802
 
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", () => this._onOpen())
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._clearStreamsSyncBarrier()
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 (/Unexpected server response: 5\d\d/.test(err?.message || "")) {
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
- this.lastRecvAt = Date.now()
245
- this.lastTickAt = Date.now()
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
- _startHeartbeat() {
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._sendJson({
489
- type: "heartbeat",
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 || this.bundleWatcher) return
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._sendJson({
607
- type: "heartbeat",
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
- _forceReconnect() {
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?.()