@cocorograph/hub-agent 0.7.0 → 0.7.1

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.0",
3
+ "version": "0.7.1",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -321,13 +321,22 @@ export function decideSessionRotation({
321
321
  newestSessionId,
322
322
  lastNotifiedNewId,
323
323
  lastNotifiedAt,
324
+ lastNotifiedCount = 0,
324
325
  now,
325
326
  reNotifyMs = 4000,
327
+ maxReNotify = 5,
326
328
  } = {}) {
327
329
  if (!newestSessionId || !viewingSessionId) return { rotated: false }
328
330
  if (newestSessionId === viewingSessionId) return { rotated: false }
329
331
  if (newestSessionId === lastNotifiedNewId) {
330
332
  // 同一 new。時刻情報が揃っていて再通知間隔を超えていれば、固着救済のため再通知する。
333
+ // ただし安全弁: 同一 (viewing→newest) ペアを maxReNotify 回再通知してもフロントが
334
+ // 採用しない場合は打ち切る (capped)。フロントが viewing を更新しない=外来 id 汚染や
335
+ // 別 cwd の rotated 誤採用の兆候で、これを無制限に再通知すると rotated 無限ピンポンに
336
+ // なる (perf監査: cockpit↔cocomiru で rotated 244件/h を実測)。
337
+ if (lastNotifiedCount >= maxReNotify) {
338
+ return { rotated: false, capped: true }
339
+ }
331
340
  if (
332
341
  typeof now === "number" &&
333
342
  typeof lastNotifiedAt === "number" &&
package/src/main.mjs CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  capturePane,
38
38
  detectPermissionModeFromText,
39
39
  detectSessionState,
40
+ invalidateSessionCache,
40
41
  listSessionStates,
41
42
  StallTracker,
42
43
  } from "./state.mjs"
@@ -895,17 +896,18 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
895
896
  readinessTracker.arm(sessionName)
896
897
  }
897
898
  }
898
- // 計装 (2026-06-19): ターン境界イベントと arm を成功送信時に記録する。従来は session.event
899
- // WS 未接続スキップ時しかログに残らず、arm→ターン進行→ready の追跡が事後にできなかった
900
- // (生成中/停止判定の障害が「ログから盲目」だった主因)。armed は prompt_submit/stop/idle_hint で立つ。
901
- logger?.info(
902
- {
903
- session_name: sessionName,
904
- event: data.event,
905
- armed: readinessTracker ? readinessTracker.isArmed(sessionName) : undefined,
906
- },
907
- "session.event push",
908
- )
899
+ // 計装 (2026-06-19, 既定 OFF=2026-06-20): ターン境界イベントと arm を記録する。常時 info は
900
+ // ログ肥大の主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す(障害解析時に有効化)。
901
+ if (STATE_TRACE) {
902
+ logger?.info(
903
+ {
904
+ session_name: sessionName,
905
+ event: data.event,
906
+ armed: readinessTracker ? readinessTracker.isArmed(sessionName) : undefined,
907
+ },
908
+ "session.event push",
909
+ )
910
+ }
909
911
  client.send({
910
912
  type: "session.event",
911
913
  session_name: sessionName,
@@ -1041,7 +1043,15 @@ function startReadinessLoop({ tracker, logger, intervalMs = 700 }) {
1041
1043
  // いれば「出力ストリーミング中」とみなし proc_busy に OR する。state loop は 5s 周期なので、
1042
1044
  // 連続生成中は毎 tick 署名が変わり常時 true、出力が止まれば次 tick (≤5s) で署名不変になり
1043
1045
  // 窓経過後に false へ落ちる (ターン終了後のスピナー固着=症状A を悪化させない短さ)。
1044
- const OUTPUT_ACTIVE_MS = Number(process.env.HUB_AGENT_OUTPUT_ACTIVE_MS ?? 8000)
1046
+ // 出力フロー検知窓。state loop tick(5000ms)の整数倍にして、出力が止まった後の outputActive
1047
+ // 5s tick 境界で true↔false フラッピングするのを防ぐ(8000 は非整数倍で proc_busy 単独反転 push が
1048
+ // 多発していた=perf監査。10000=2tick で安定側、影響は『出力停止とみなす猶予 8s→10s』のみ)。
1049
+ const OUTPUT_ACTIVE_MS = Number(process.env.HUB_AGENT_OUTPUT_ACTIVE_MS ?? 10000)
1050
+
1051
+ // 状態信号の計装ログ(session.state/event push)を出すか。既定 OFF。障害解析時に
1052
+ // HUB_AGENT_STATE_TRACE=1 で一時的に有効化する(常時 info 出力はログ肥大の主因=perf監査で
1053
+ // 直近1h ログの約半分を占めると実測。挙動には一切影響しない純ログ)。
1054
+ const STATE_TRACE = process.env.HUB_AGENT_STATE_TRACE === "1"
1045
1055
 
1046
1056
  // 権威的な「応答停止 (ハング)」判定の無進捗しきい値。ターン進行中 (armed) なのに、実際の
1047
1057
  // 進捗 (子プロセス実行中 / 出力フロー / ペインのライブ processing) がこの時間継続して観測
@@ -1179,10 +1189,10 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1179
1189
  proc_busy: procBusy,
1180
1190
  stalled,
1181
1191
  })
1182
- // 計装 (2026-06-19): 状態の「変化時」(差分送信なので低頻度) に値を記録する。従来は
1183
- // session.state WS 未接続スキップ時しかログに残らず、proc_busy/stalled/status の実値が
1184
- // 事後追跡できなかった (生成中/停止判定の障害がログから検証不能だった主因)。childBusy と
1185
- // outputActive を分けて出し、proc 内省と出力フローのどちらが busy を立てたか切り分け可能にする。
1192
+ // 計装 (2026-06-19, 既定 OFF=2026-06-20): 状態変化時に値を記録する。常時 info はログ肥大の
1193
+ // 主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す。childBusy/outputActive を分けて出し、
1194
+ // proc 内省と出力フローのどちらが busy を立てたか切り分け可能にする(障害解析用)。
1195
+ if (STATE_TRACE) {
1186
1196
  logger?.info(
1187
1197
  {
1188
1198
  session_name: s.session_name,
@@ -1197,6 +1207,7 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1197
1207
  },
1198
1208
  "session.state push",
1199
1209
  )
1210
+ }
1200
1211
  client.send({
1201
1212
  type: "session.state",
1202
1213
  session_name: s.session_name,
@@ -1208,6 +1219,22 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1208
1219
  stalled,
1209
1220
  })
1210
1221
  }
1222
+ // 消滅セッションの GC (perf監査/メモリリーク対策): tmux から消えたセッションの状態 Map を
1223
+ // まとめて解放する。clear() は再接続時(onReopen)のみのため、worktree 案件の生成/破棄で
1224
+ // 各 per-session Map が線形増加し RSS が無入力時も成長していた。tmux が一時的に空応答
1225
+ // (states=[]) のときは全消去しないよう states.length>0 をガードにする。
1226
+ if (states.length > 0) {
1227
+ const liveNames = new Set(states.map((s) => s.session_name))
1228
+ for (const name of [...lastByName.keys()]) {
1229
+ if (liveNames.has(name)) continue
1230
+ invalidateSessionCache(name) // capture/cwd/statusGate/spinnerFreeze/stability (state.mjs)
1231
+ stallTracker.forget(name) // StallTracker.byName
1232
+ readinessTracker?.forget?.(name) // ReadinessTracker.byName
1233
+ lastByName.delete(name)
1234
+ outputFlowByName.delete(name)
1235
+ lastTurnAtByName.delete(name)
1236
+ }
1237
+ }
1211
1238
  }
1212
1239
  } catch (err) {
1213
1240
  logger?.warn({ err: err.message }, "state loop tick failed")
@@ -1475,13 +1502,26 @@ async function dispatch(msg, ctx) {
1475
1502
  }
1476
1503
  handleUntrackedPtyData(msg, ctx)
1477
1504
  return
1478
- case "pty.resize":
1479
- ctx.ptyBridge.resize({
1505
+ case "pty.resize": {
1506
+ const resized = ctx.ptyBridge.resize({
1480
1507
  stream_id: msg.stream_id,
1481
1508
  cols: msg.cols,
1482
1509
  rows: msg.rows,
1483
1510
  })
1511
+ // 自己修復 (perf監査 Phase3): stream 不在で resize できなかった (reap 済み等) ときは
1512
+ // pty.error(stream_missing) を返し browser を再 attach させる。keepalive resize が dead
1513
+ // stream を即検知して 4 分単位の「pty.resize but stream missing」churn を自己修復する
1514
+ // (handleTrackedPtyData/handleUntrackedPtyData と同じパターン)。resize 例外 (pty 存在) も
1515
+ // false だが、その場合の再 attach は既存 stream への無害な再接続。
1516
+ if (!resized && msg.stream_id) {
1517
+ ctx.client.send({
1518
+ type: "pty.error",
1519
+ stream_id: msg.stream_id,
1520
+ error: "stream_missing",
1521
+ })
1522
+ }
1484
1523
  return
1524
+ }
1485
1525
  case "pty.detach":
1486
1526
  ctx.ptyBridge.detach({ stream_id: msg.stream_id })
1487
1527
  return
@@ -1692,20 +1732,37 @@ async function dispatch(msg, ctx) {
1692
1732
  const key = viewName || viewCwd
1693
1733
  const prev = ctx.tuiRotationNotified.get(key)
1694
1734
  const now = Date.now()
1695
- const { rotated, newSessionId } = decideSessionRotation({
1735
+ const prevNewId =
1736
+ prev && typeof prev === "object" ? prev.newId : prev
1737
+ const prevCount =
1738
+ prev && typeof prev === "object" ? (prev.count ?? 0) : 0
1739
+ const { rotated, newSessionId, capped } = decideSessionRotation({
1696
1740
  viewingSessionId: viewSid,
1697
1741
  newestSessionId: newestId,
1698
- // 旧形式 (文字列) と新形式 ({newId, ts}) の両対応。
1699
- lastNotifiedNewId:
1700
- prev && typeof prev === "object" ? prev.newId : prev,
1742
+ // 旧形式 (文字列) と新形式 ({newId, ts, count}) の両対応。
1743
+ lastNotifiedNewId: prevNewId,
1701
1744
  lastNotifiedAt:
1702
1745
  prev && typeof prev === "object" ? prev.ts : null,
1746
+ lastNotifiedCount: prevCount,
1703
1747
  now,
1704
1748
  })
1749
+ if (capped) {
1750
+ // 安全弁発火: フロントが採用しないまま maxReNotify 回再通知済み。これ以上の
1751
+ // rotated 連発を止め、無限ピンポンを断つ (フロント側 cwd 必須化で通常は到達しない)。
1752
+ logger.warn(
1753
+ { session: viewName, cwd: viewCwd, view: viewSid, newest: newestId },
1754
+ "tui session rotation: capped (frontend not adopting; stopped re-notify)",
1755
+ )
1756
+ return
1757
+ }
1705
1758
  if (!rotated) return
1706
- // {newId, ts} で保存し、同一 new への再通知をスロットリングする
1707
- // (固着した viewer throttle 間隔ごとに 1 回ずつ救済通知を受ける)。
1708
- ctx.tuiRotationNotified.set(key, { newId: newSessionId, ts: now })
1759
+ // {newId, ts, count} で保存。同一 new への再通知をスロットリング+回数キャップする。
1760
+ // newId が変われば count 1 にリセット、同一なら加算する。
1761
+ ctx.tuiRotationNotified.set(key, {
1762
+ newId: newSessionId,
1763
+ ts: now,
1764
+ count: newSessionId === prevNewId ? prevCount + 1 : 1,
1765
+ })
1709
1766
  // 冪等マップも新 id へ更新し、将来の remount bind が旧 id へ
1710
1767
  // respawn (claude 再起動) してしまうのを防ぐ。
1711
1768
  if (viewName) ctx.tuiReboundSessions.set(viewName, newSessionId)
package/src/state.mjs CHANGED
@@ -47,12 +47,15 @@ export function invalidateSessionCache(sessionName) {
47
47
  _cwdCache.clear()
48
48
  _statusGate.clear()
49
49
  _spinnerFreezeByName.clear()
50
+ _stabilityByName.clear()
50
51
  return
51
52
  }
52
53
  _captureCache.delete(sessionName)
53
54
  _cwdCache.delete(sessionName)
54
55
  _statusGate.delete(sessionName)
55
56
  _spinnerFreezeByName.delete(sessionName)
57
+ // _stabilityByName も per-session で消す (消滅セッションの GC 漏れ=メモリリーク対策, perf監査)。
58
+ _stabilityByName.delete(sessionName)
56
59
  }
57
60
 
58
61
  /**