@cocorograph/hub-agent 0.7.73 → 0.7.74

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.73",
3
+ "version": "0.7.74",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -61,35 +61,42 @@ function siblingEnvFor(cwd) {
61
61
  }
62
62
  }
63
63
 
64
- // ⚠️ 2026-07-17 の事故 (「Codex チャットが 5 分で消え ask-sibling が届かない」) の教訓:
65
- // この TTL は **detach 済み (= ブラウザが居なくなった) セッション専用** の猶予である。
66
- // 旧実装は (a) detach の有無を記録せず turn/completed のたびに無条件でタイマーを張り、
67
- // (b) タイマー発火時に activeTurnId を再チェックしなかったため、
68
- // - ブラウザを開いたままでも「最後のターン完了から 5 分」で app-server が kill され、
69
- // - その 5 分間に ask-sibling が新ターンを注入すると **exec 実行中に kill** され、
70
- // rollout に output の無い custom_tool_call が残って thread が永久に汚染された
71
- // (codex_core の `Custom tool call output is missing for call id: ...` が以後の
72
- // 全ターンで出続ける)。
73
- // 予約は detach 済みセッションに限り、発火時にも busy/再活性を再確認すること。
74
- // detach 後、非 busy なセッションを生かしておく猶予。claude 側 (7 日) と違い短くする:
75
- // この TTL は **in-memory reattach の猶予窓** を兼ねる。WS が切れて browser disconnect が
76
- // codex.detach を送っても、この TTL 内に **同じ attach_id で再接続** すれば _tryReattach が
77
- // その session を新 stream へ付け替えて拾える (fresh 直後 rollout 0件の会話も復元可)。TTL を
78
- // 超えて reap されたら、再接続は通常経路 (thread/resume は rollout 正本から復元、または fresh)
79
- // にフォールスルーする。7 日だと再接続チャーンのたびにプロセスが積み上がる (リーク) ため短命に
80
- // する。ターン実行中は TTL に
81
- // 関わらず完走まで絶対に落とさない (_maybeReapAfterIdle が busy 中は予約しない)。
82
- const IDLE_DETACH_TTL_MS = 5 * 60 * 1000
64
+ // ── Stage 5 (2026-07-19): bounded warm pool + 長 idle TTL への再設計 ───────────
65
+ // 旧設計: detach → **5 分** の per-session タイマーで reap(`_maybeReapAfterIdle`)+
66
+ // 1h の無活性 reaper(`gcAbandonedSessions`)。これは codex-review のように 1 ターンが
67
+ // 数分〜数十分かかり、かつ **別 CLI 画面(claude チャット)から呼ぶため codex 画面が非
68
+ // アクティブ = detach 済み**になる用途で、「ターン完了後 5 分の idle で reaped → 次の依頼が
69
+ // 届かない」構造的ミスマッチを生んでいた(2026-07-19 調査)。
70
+ //
71
+ // 新設計: **周期 reconcile 一本化**(`reconcileSessions`、state loop から ~5s 毎)。
72
+ // - detach してもすぐ殺さず、**長 idle TTL(既定 7 日、claude softDetach と同思想)** で
73
+ // warm 保持する。
74
+ // - 際限ないプロセス蓄積は **LRU 件数上限(既定 5、マシン全体)** で防ぐ。6 個目が来たら
75
+ // 最も使っていない **非 protected** セッションを 1 個 reap。旧「5 分で殺す」の代わりに
76
+ // 「上限で天井を作る」ことで、TTL を長くしてもリークしない(T06500 の churn 削減も前提)。
77
+ // - **busy(healthy running turn)は絶対に reap しない**(TTL でも LRU でも)。
78
+ // - **stuck turn**(activeTurnId 固着だが app-server 通知が STUCK_TTL 途絶えた=ハング)は
79
+ // protected から外れ、別途 reap 対象(プロセスリーク・固着の救済)。
80
+ // これにより意味論は「**閲覧中=無期限(keepalive で LRU 先頭維持)/ 直近 5 セッションは
81
+ // detach しても 7 日 warm / 上限超過は LRU で 1 個 reap / busy は不可侵 / stuck は救済**」。
82
+
83
+ // warm 保持の idle TTL(非 busy がこの時間 無活性なら reap の対象。上限内でも 7 日で頭打ち)。
84
+ const IDLE_DETACH_TTL_MS =
85
+ Number(process.env.HUB_AGENT_CODEX_IDLE_DETACH_TTL_MS) || 7 * 24 * 60 * 60 * 1000
83
86
 
84
- // 放棄セッションの reaper 閾値。claude-stream-bridge.mjs の gcDeadTerminals と同じ思想・
85
- // 同じ env (HUB_AGENT_DEAD_TERMINAL_TTL_MS、既定 1 時間)。detach を送らず消えた端末や、
86
- // codex エラーで turn/completed が来ず activeTurnId が固着したスタックセッションは、
87
- // detach→TTL(5分) の graceful reap 経路に乗らないため app-server が永久に残る (リーク)。
88
- // これを補い、「一定時間まったく通信 (browser 入力 / app-server 通知) が無い」セッションを
89
- // activeTurnId の有無に関わらず定期回収する。進行中の正常なターンは app-server 通知で
90
- // 都度 touch されるため回収されない (通知が途絶えたスタックターンのみ回収される)。
91
- const ABANDONED_SESSION_TTL_MS =
92
- Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) || 60 * 60 * 1000
87
+ // LRU warm pool の上限(マシン全体で同時に生かす codex セッション数)。超過分は最も使って
88
+ // いない非 protected セッションから reap。1 セッション ≈ 120-140MB(node ラッパー + 本体 +
89
+ // MCP 子)なので既定 5(~650MB)。env で調整可。
90
+ const WARM_POOL_MAX = Number(process.env.HUB_AGENT_CODEX_WARM_POOL_MAX) || 5
91
+
92
+ // stuck turn 検出の閾値。activeTurnId が立っているのに app-server 通知がこの時間途絶えた
93
+ // セッションは「ハングしたターン」とみなし protected から外して reap する(healthy な長ターンは
94
+ // reasoning/output delta で都度 touch されるため該当しない)。旧 `HUB_AGENT_DEAD_TERMINAL_TTL_MS`
95
+ // を後方互換で受ける。既定 1h。
96
+ const STUCK_TURN_TTL_MS =
97
+ Number(process.env.HUB_AGENT_CODEX_STUCK_TURN_TTL_MS) ||
98
+ Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) ||
99
+ 60 * 60 * 1000
93
100
 
94
101
  const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
95
102
 
@@ -235,6 +242,8 @@ export class CodexStreamBridge extends EventEmitter {
235
242
  logger,
236
243
  clientFactory,
237
244
  detachTtlMs,
245
+ warmPoolMax,
246
+ stuckTurnTtlMs,
238
247
  rolloutPresence,
239
248
  historyMaxItems,
240
249
  historyMaxBytes,
@@ -243,8 +252,12 @@ export class CodexStreamBridge extends EventEmitter {
243
252
  this.codexCmd = codexCmd
244
253
  this.logger = logger
245
254
  this._clientFactory = clientFactory || ((opts) => new CodexAppServerClient(opts))
246
- // テストから TTL を短縮できるよう注入可能にする (既定は 7 日)。
255
+ // Stage 5: warm 保持の idle TTL(既定 7 日)。テストから短縮できるよう注入可能。
247
256
  this._detachTtlMs = detachTtlMs ?? IDLE_DETACH_TTL_MS
257
+ // Stage 5: LRU warm pool 上限(既定 5)。テストから小さい値を注入可能。
258
+ this._warmPoolMax = warmPoolMax ?? WARM_POOL_MAX
259
+ // Stage 5: stuck turn 検出 TTL(既定 1h)。テストから短縮可能。
260
+ this._stuckTurnTtlMs = stuckTurnTtlMs ?? STUCK_TURN_TTL_MS
248
261
  // #4 失敗分類の rollout 存在チェック (tri-state。テストから注入可能)。
249
262
  this._rolloutPresence = rolloutPresence || codexRolloutPresence
250
263
  // T06499: 履歴 hydrate の上限 (テストから小さい値を注入可能)。
@@ -318,14 +331,15 @@ export class CodexStreamBridge extends EventEmitter {
318
331
  threadId: null,
319
332
  activeTurnId: null,
320
333
  pendingApprovals: new Map(), // request_id -> { turnId }
321
- detachTimer: null,
322
- // ブラウザから codex.detach を受け取った (= 画面の持ち主が居なくなった) か。
323
- // アイドル TTL reap は **このフラグが立っているセッションだけ** が対象。
334
+ // ブラウザから codex.detach を受け取った (= 画面の持ち主が居なくなった) か。診断・ログ用。
335
+ // Stage 5: 回収の可否は detachRequested ではなく lastActivityMs(LRU)と activeTurnId
336
+ // (busy/stuck)で決める。detach してもすぐには殺さず warm 保持する。
324
337
  // ask-sibling 注入 (sendToCwd) は「新しい持ち主が付いた」とみなして false に戻す。
325
338
  detachRequested: false,
326
339
  dead: false,
327
- // 放棄セッション reaper (gcAbandonedSessions) 用の最終活性時刻。browser 入力と
328
- // app-server 通知 (ターン進捗) の両方で更新する。Date.now() は hub-agent 実行環境で利用可。
340
+ // Stage 5 reconcile(LRU / idle TTL / stuck)用の最終活性時刻。browser 入力と app-server
341
+ // 通知 (ターン進捗) の両方で更新する。LRU は「最終活性が最も古い非 protected」を退避する。
342
+ // Date.now() は hub-agent 実行環境で利用可。
329
343
  lastActivityMs: Date.now(),
330
344
  ready: null,
331
345
  }
@@ -389,6 +403,10 @@ export class CodexStreamBridge extends EventEmitter {
389
403
  },
390
404
  "codex session attached",
391
405
  )
406
+ // Stage 5: genuine attach で 1 プロセス増えたので、LRU 上限の超過分を即時退避する
407
+ // (次の reconnect まで ~5s 待たず、6 個目の app-server が居座るのを防ぐ)。新セッションは
408
+ // たった今 touch 済み = LRU 先頭なので退避対象にならず、最も古い非 protected が落ちる。
409
+ this._evictWarmPoolOverflow(Date.now(), "lru-evict-on-attach")
392
410
  return {
393
411
  resuming,
394
412
  threadId: session.threadId,
@@ -444,7 +462,6 @@ export class CodexStreamBridge extends EventEmitter {
444
462
  this.sessions.delete(originalStreamId)
445
463
  session.stream_id = newStreamId
446
464
  session.detachRequested = false
447
- this._cancelIdleReap(session, "reattach")
448
465
  session.lastActivityMs = Date.now()
449
466
  this.sessions.set(newStreamId, session)
450
467
  this.logger?.info?.(
@@ -573,9 +590,8 @@ export class CodexStreamBridge extends EventEmitter {
573
590
  case "turn/completed":
574
591
  session.activeTurnId = null
575
592
  this._autoResolveStalePendingApprovals(session, msg.params?.turn?.id)
576
- // ⚠️ detach 済みのときだけ再評価する。ブラウザが繋がったままのセッションは
577
- // ターンが終わっても生かし続ける (_maybeReapAfterIdle 内でも二重に guard)。
578
- this._maybeReapAfterIdle(session, "turn-completed-after-detach")
593
+ // Stage 5: turn 完了後の reap 予約は不要。以降 idle になれば周期 reconcile が
594
+ // 長 idle TTL / LRU で扱う(detach 済みでも 7 日 warm 保持)。
579
595
  break
580
596
  default:
581
597
  break
@@ -615,7 +631,6 @@ export class CodexStreamBridge extends EventEmitter {
615
631
  _onClientExit(session, info) {
616
632
  if (session.dead) return
617
633
  session.dead = true
618
- this._clearDetachTimer(session)
619
634
  this.sessions.delete(session.stream_id)
620
635
  // app-server の終了は必ず記録する。ターン実行中の exit は「rollout に output 欠落の
621
636
  // custom_tool_call を残した可能性がある」= thread 汚染の予兆なので warn で出す
@@ -665,11 +680,9 @@ export class CodexStreamBridge extends EventEmitter {
665
680
  */
666
681
  async input({ stream_id, message, model, effort }) {
667
682
  const session = this._require(stream_id)
683
+ // Stage 5: _touch で lastActivityMs を更新 = LRU 先頭へ。ターン開始で activeTurnId が
684
+ // 立ち、reconcile は healthy busy を protected 扱いにするので実行中に kill されない。
668
685
  this._touch(session)
669
- // TTL 待ちのセッションに新ターンを流す = まだ使われている。予約を解除しないと
670
- // ターン実行中 (exec 中) に kill され、rollout に output 欠落の custom_tool_call が
671
- // 残って thread が汚染される (2026-07-17 の事故)。
672
- this._cancelIdleReap(session, "input")
673
686
  const input = userInputFromMessage(message)
674
687
  if (session.activeTurnId) {
675
688
  try {
@@ -718,7 +731,6 @@ export class CodexStreamBridge extends EventEmitter {
718
731
  async interrupt({ stream_id }) {
719
732
  const session = this._require(stream_id)
720
733
  this._touch(session)
721
- this._cancelIdleReap(session, "interrupt")
722
734
  if (!session.activeTurnId) return
723
735
  await session.client.request("turn/interrupt", {
724
736
  threadId: session.threadId,
@@ -730,7 +742,6 @@ export class CodexStreamBridge extends EventEmitter {
730
742
  permissionReply({ stream_id, request_id, result }) {
731
743
  const session = this._require(stream_id)
732
744
  this._touch(session)
733
- this._cancelIdleReap(session, "permission-reply")
734
745
  if (!session.pendingApprovals.has(request_id)) {
735
746
  this.logger?.warn?.({ stream_id, request_id }, "codex permissionReply: unknown request_id (ignored)")
736
747
  return
@@ -742,13 +753,9 @@ export class CodexStreamBridge extends EventEmitter {
742
753
  /**
743
754
  * ブラウザ閲覧中の生存シグナル (codex.keepalive)。frontend が Codex チャットを開いて
744
755
  * いる間だけ定期送信するため、これが届く = 「人が見ている」の観測可能な証跡になる。
745
- * gcAbandonedSessions は lastActivityMs しか見ないので、touch するだけで
746
- * 「開いたまま無入力でも 1h で殺される」ギャップが閉じる (TTL の数字を増やす必要は無い)。
747
- *
748
- * ⚠️ 意図的に `_cancelIdleReap` を **呼ばない**。detach 済みセッションの 5 分 TTL は
749
- * 「ブラウザが去った」という別事実に基づく予約であり、detach と in-flight keepalive が
750
- * 競合しても予約を解除しない (= 明示 detach は必ず 5 分で回収される) 方が意味論が明快。
751
- * 閲覧を再開したブラウザは必ず attach を送り直すので、再養子はそちらの経路で足りる。
756
+ * Stage 5: touch で lastActivityMs を更新すると LRU 先頭に留まるため、**閲覧中のセッションは
757
+ * 上限超過の LRU 退避対象にならず、長 idle TTL でも殺されない**(= 実質無期限)。ブラウザを
758
+ * 閉じれば keepalive も止まり、以降は detach 済み warm として LRU / 7 日 idle TTL に従う。
752
759
  *
753
760
  * @returns {boolean} 生きているセッションを touch できたら true (未知 stream は false)。
754
761
  */
@@ -760,9 +767,9 @@ export class CodexStreamBridge extends EventEmitter {
760
767
  }
761
768
 
762
769
  /**
763
- * グレースフル切断。ターン実行中は完走を待ち、非 busy ならアイドル TTL 後に
764
- * プロセスを reap する (claude-stream-bridge.mjs の softDetach と同じ思想。
765
- * Slice 2 は多端末共有非対応なので detach = そのまま TTL 予約でよい)。
770
+ * グレースフル切断(Stage 5)。**すぐには reap しない**。detachRequested を立てて
771
+ * warm 保持し、以降は周期 reconcile(LRU 上限 / 長 idle TTL / stuck)に委ねる。これにより
772
+ * 「別 CLI 画面へ切替えた瞬間 5 分で殺される」問題を解消し、直近 5 セッションは 7 日 warm に。
766
773
  */
767
774
  detach({ stream_id }) {
768
775
  const session = this.sessions.get(stream_id)
@@ -775,9 +782,8 @@ export class CodexStreamBridge extends EventEmitter {
775
782
  cwd: session.cwd,
776
783
  busy: !!session.activeTurnId,
777
784
  },
778
- "codex detach requested by browser",
785
+ "codex detach requested by browser (kept warm; reconcile handles reap)",
779
786
  )
780
- this._maybeReapAfterIdle(session, "detach")
781
787
  }
782
788
 
783
789
  /**
@@ -862,10 +868,11 @@ export class CodexStreamBridge extends EventEmitter {
862
868
  )
863
869
  return false
864
870
  }
865
- // TTL 待ち (browser detach 済み) のセッションでも、ask-sibling が使うなら
866
- // 「新しい持ち主が付いた」とみなして回収予約を解除する。これをしないと
867
- // 注入したターンの実行中に kill され、thread が汚染される (2026-07-17)。
868
- this._cancelIdleReap(best, "sendToCwd (ask-sibling injection)", { readopt: true })
871
+ // detach 済み warm セッションでも、ask-sibling が使うなら「新しい持ち主が付いた」とみなして
872
+ // detachRequested を解除し touch する(LRU 先頭へ)。this.input が改めて _touch するが、注入前に
873
+ // 明示しておく。ターン実行中は healthy busy = protected なので reconcile で kill されない。
874
+ best.detachRequested = false
875
+ this._touch(best)
869
876
  this.logger?.info?.(
870
877
  {
871
878
  cwd,
@@ -888,92 +895,70 @@ export class CodexStreamBridge extends EventEmitter {
888
895
  }
889
896
 
890
897
  /**
891
- * detach 済み・非 busy なセッションに「アイドル TTL 後の回収」を予約する。
892
- *
893
- * ⚠️ 予約は `detachRequested` が立っているセッションのみ。ブラウザが繋がったままの
894
- * セッションを turn/completed 契機で殺してはいけない (2026-07-17 の事故)。
898
+ * Stage 5: セッションが「保護対象(reap 不可)」か。= **healthy な running turn**。
899
+ * activeTurnId が立っており、かつ app-server 通知が STUCK_TTL 以内に届いている(=生きている
900
+ * ターン)。activeTurnId が固着したまま通知が STUCK_TTL 途絶えたもの(stuck/ハング)は
901
+ * protected ではなく reap 対象にする(プロセスリーク・固着の救済)。
895
902
  * @param {object} session
896
- * @param {string} trigger 予約理由 (ログ用: 'detach' | 'turn-completed-after-detach')
903
+ * @param {number} now
904
+ * @returns {boolean}
897
905
  */
898
- _maybeReapAfterIdle(session, trigger = "detach") {
899
- if (!session.detachRequested) return // ブラウザが居る → 回収対象外
900
- if (session.activeTurnId) return // 完走まで待つ (turn/completed で再評価される)
901
- this._clearDetachTimer(session)
902
- session.detachTimer = setTimeout(() => {
903
- if (session.dead) return
904
- // 発火時の再確認: TTL 中に ask-sibling 注入 (sendToCwd) で再活性化した、または
905
- // 新しいターンが走り出したセッションを **実行中に kill しない** (rollout に
906
- // output 欠落の custom_tool_call を残さないため)。
907
- if (!session.detachRequested) {
908
- this.logger?.info?.(
909
- { stream_id: session.stream_id, thread_id: session.threadId, cwd: session.cwd },
910
- "codex idle reap canceled at fire time (session re-adopted)",
911
- )
912
- session.detachTimer = null
913
- return
914
- }
915
- if (session.activeTurnId) {
916
- this.logger?.info?.(
917
- {
918
- stream_id: session.stream_id,
919
- thread_id: session.threadId,
920
- cwd: session.cwd,
921
- turn_id: session.activeTurnId,
922
- },
923
- "codex idle reap deferred (turn in progress; will re-arm on turn/completed)",
924
- )
925
- session.detachTimer = null
926
- return
927
- }
928
- this.logger?.info?.(
929
- {
930
- stream_id: session.stream_id,
931
- thread_id: session.threadId,
932
- cwd: session.cwd,
933
- ttl_ms: this._detachTtlMs,
934
- reason: "idle-detach-ttl",
935
- },
936
- "codex session reaped (detached and idle past TTL)",
937
- )
906
+ _isProtected(session, now) {
907
+ if (!session.activeTurnId) return false
908
+ return now - (session.lastActivityMs ?? now) <= this._stuckTurnTtlMs
909
+ }
910
+
911
+ /** 実際に app-server を止めてセッションを撤去し exit を emit する(reconcile 共通処理)。 */
912
+ _reapSession(session, now, reason) {
913
+ if (session.dead) return
914
+ session.dead = true
915
+ this.sessions.delete(session.stream_id)
916
+ try {
938
917
  session.client.stop()
939
- }, this._detachTtlMs)
940
- session.detachTimer.unref?.()
918
+ } catch (err) {
919
+ this.logger?.warn?.({ stream_id: session.stream_id, err: err?.message }, "codex reap: stop threw")
920
+ }
941
921
  this.logger?.info?.(
942
922
  {
943
923
  stream_id: session.stream_id,
944
924
  thread_id: session.threadId,
945
925
  cwd: session.cwd,
946
- ttl_ms: this._detachTtlMs,
947
- trigger,
926
+ reason,
927
+ idle_ms: now - (session.lastActivityMs ?? now),
928
+ live_after: this.sessions.size,
948
929
  },
949
- "codex idle reap armed (browser detached)",
930
+ "codex session reaped",
950
931
  )
932
+ this.emit("exit", {
933
+ stream_id: session.stream_id,
934
+ thread_id: session.threadId,
935
+ code: 0,
936
+ reason: "idle-reaped",
937
+ })
951
938
  }
952
939
 
953
940
  /**
954
- * 予約済みのアイドル回収を取り消す。ブラウザ入力 / ask-sibling 注入 / 承認応答など
955
- * 「このセッションはまだ使われている」証跡が出たら必ず呼ぶ。
956
- * @param {object} session
957
- * @param {string} reason ログ用
958
- * @param {boolean} readopt true なら detachRequested も解除 (新しい持ち主が付いた)
941
+ * Stage 5: LRU warm pool 上限の超過分を退避する。生存(非 dead)セッションが上限を超えたら、
942
+ * **非 protected の中で最終活性が最も古いもの**から順に上限まで reap する。protected(healthy
943
+ * running turn)は退避対象にしないため、全部が busy のときは一時的に上限を超えたまま許容する
944
+ * (走行中ターンを絶対に殺さない)。genuine attach 直後と reconcile の両方から呼ぶ。
945
+ * @param {number} now
946
+ * @returns {number} 退避した数
959
947
  */
960
- _cancelIdleReap(session, reason, { readopt = false } = {}) {
961
- const had = !!session.detachTimer
962
- this._clearDetachTimer(session)
963
- if (readopt) session.detachRequested = false
964
- if (had) {
965
- this.logger?.info?.(
966
- { stream_id: session.stream_id, thread_id: session.threadId, cwd: session.cwd, reason },
967
- "codex idle reap canceled (session active again)",
968
- )
969
- }
970
- }
971
-
972
- _clearDetachTimer(session) {
973
- if (session.detachTimer) {
974
- clearTimeout(session.detachTimer)
975
- session.detachTimer = null
948
+ _evictWarmPoolOverflow(now, reason = "lru-evict") {
949
+ const live = Array.from(this.sessions.values()).filter((s) => !s.dead)
950
+ const overflow = live.length - this._warmPoolMax
951
+ if (overflow <= 0) return 0
952
+ const evictable = live
953
+ .filter((s) => !this._isProtected(s, now))
954
+ .sort((a, b) => (a.lastActivityMs ?? 0) - (b.lastActivityMs ?? 0)) // 古い順
955
+ let evicted = 0
956
+ for (const s of evictable) {
957
+ if (evicted >= overflow) break
958
+ this._reapSession(s, now, reason)
959
+ evicted += 1
976
960
  }
961
+ return evicted
977
962
  }
978
963
 
979
964
  _require(stream_id) {
@@ -985,64 +970,49 @@ export class CodexStreamBridge extends EventEmitter {
985
970
  }
986
971
 
987
972
  /**
988
- * 放棄セッションの reaper。claude-stream-bridge.mjs の gcDeadTerminals と同じ思想:
989
- * 一定時間まったく通信 (browser 入力 / app-server 通知) が無いセッションの app-server を
990
- * 停止して回収する。detach を送らず消えた端末や、codex エラーで turn/completed が来ず
991
- * activeTurnId が固着したスタックターンは detach→TTL の graceful reap 経路に乗らないため、
992
- * これが唯一の回収経路になる (プロセスリーク / 生成中固着の根治)。
973
+ * Stage 5: セッションの周期 reconcile(state loop から ~5s 毎に実行)。以下を 1 パスで行う:
974
+ * 1. **stuck turn**: activeTurnId 固着だが app-server 通知が STUCK_TTL 途絶えた(ハング)→ reap
975
+ * (healthy な長ターンは reasoning/output delta で touch されるため該当しない)。
976
+ * 2. **long idle**: 非 busy が idle TTL(既定 7 日)無活性 → reap(warm 保持の期限切れ)。
977
+ * 3. **LRU 上限**: 生存数が warmPoolMax(既定 5)を超えたら、非 protected の最終活性が
978
+ * 最も古いものから上限まで reap。
979
+ * busy(healthy running turn)は 1〜3 いずれの対象にもならない(走行中ターンを絶対に殺さない)。
993
980
  *
994
- * 進行中の正常なターンは app-server 通知で都度 touch されるので回収されない。
995
- * main.mjs の state loop から claudeBridge.gcDeadTerminals と並べて定期実行する想定。
981
+ * 旧 `gcAbandonedSessions`(1h 無活性一律 reap)を置換。detach 済み warm セッションを 1h で
982
+ * 殺していた挙動を廃し、上限+長 TTL に一本化した。main.mjs の state loop 呼び出しは
983
+ * 後方互換のため `gcAbandonedSessions` エイリアスを残す。
996
984
  *
997
- * この reaper は `detachRequested` を **意図的に参照しない**。detach を送れずに消えた
998
- * ブラウザ (タブごとクラッシュ / 端末スリープ) は detachRequested が立たないまま残るため、
999
- * ここでフラグを見ると回収不能になりリークする。「無通信が続いた事実」だけで回収する。
1000
- *
1001
- * 閲覧中の誤爆は keepalive で防ぐ (2026-07-17 に解消。旧: 開いたままでも 1h 無入力で
1002
- * 回収されていた)。frontend は Codex チャットを開いている間 codex.keepalive を定期送信し、
1003
- * keepalive() が lastActivityMs を touch する。ブラウザが閉じれば keepalive も止まるので
1004
- * この reaper は本来の orphan GC として正しく効く。CockpitTerminal.tsx の PTY keepalive
1005
- * (pty.resize で lastSeenAt を触る) と同じ構造。結果として TTL の意味は
1006
- * 「ブラウザ開 = 無期限 / 明示 detach = 5 分 / ブラウザ落ち = 1h reaper」に整理され、
1007
- * 「N 時間放置しても生きていてほしい」という数字合わせ自体が不要になった。
1008
- *
1009
- * @param {number} [ttlMs] 無通信とみなす閾値 (テスト用に上書き可)。
1010
985
  * @returns {number} 回収したセッション数。
1011
986
  */
1012
- gcAbandonedSessions(ttlMs = ABANDONED_SESSION_TTL_MS) {
987
+ reconcileSessions() {
1013
988
  const now = Date.now()
1014
989
  let reaped = 0
990
+ // 1〜2: stuck turn / long idle。protected(healthy running turn)はスキップ。
1015
991
  for (const session of Array.from(this.sessions.values())) {
1016
992
  if (session.dead) continue
1017
- const last = session.lastActivityMs ?? 0
1018
- if (now - last < ttlMs) continue
1019
- this._clearDetachTimer(session)
1020
- session.dead = true
1021
- this.sessions.delete(session.stream_id)
1022
- try {
1023
- session.client.stop()
1024
- } catch (err) {
1025
- this.logger?.warn?.({ stream_id: session.stream_id, err: err?.message }, "gcAbandonedSessions: stop threw")
993
+ if (this._isProtected(session, now)) continue
994
+ const idle = now - (session.lastActivityMs ?? now)
995
+ if (session.activeTurnId && idle > this._stuckTurnTtlMs) {
996
+ this._reapSession(session, now, "stuck-turn")
997
+ reaped += 1
998
+ } else if (!session.activeTurnId && idle > this._detachTtlMs) {
999
+ this._reapSession(session, now, "idle-ttl")
1000
+ reaped += 1
1026
1001
  }
1027
- reaped += 1
1028
- this.logger?.info?.(
1029
- { stream_id: session.stream_id, thread_id: session.threadId, idle_ms: now - last },
1030
- "codex abandoned session reaped (no activity past TTL)",
1031
- )
1032
- this.emit("exit", {
1033
- stream_id: session.stream_id,
1034
- thread_id: session.threadId,
1035
- code: 0,
1036
- reason: "idle-reaped",
1037
- })
1038
1002
  }
1003
+ // 3: LRU 上限の超過分を退避(stuck/idle reap 後の生存数で判定)。
1004
+ reaped += this._evictWarmPoolOverflow(now, "lru-evict")
1039
1005
  return reaped
1040
1006
  }
1041
1007
 
1008
+ /** 後方互換エイリアス(main.mjs state loop が呼ぶ)。中身は reconcileSessions。 */
1009
+ gcAbandonedSessions() {
1010
+ return this.reconcileSessions()
1011
+ }
1012
+
1042
1013
  /** 全セッションを即座に終了する (agent shutdown 用。ターン完走は待たない)。 */
1043
1014
  shutdown() {
1044
1015
  for (const session of this.sessions.values()) {
1045
- this._clearDetachTimer(session)
1046
1016
  session.dead = true
1047
1017
  // hub-agent 再起動時にターン実行中のセッションを落とすと thread が汚染されうる
1048
1018
  // (exec 中の kill → output 欠落)。因果を後から辿れるよう必ず記録する。
package/src/main.mjs CHANGED
@@ -4399,11 +4399,23 @@ async function dispatch(msg, ctx) {
4399
4399
  }
4400
4400
  try {
4401
4401
  const sessions = await ctx.codexBridge.listThreads({ cwd, limit })
4402
+ // Stage 5 ③: 各 thread に「hub-agent 側で app-server が live か / busy か」を付す。
4403
+ // rollout 由来の一覧(listThreads)は live 状態を知らないため、describeLiveSessions
4404
+ // の thread_id → {live, busy} を併合する。browser は sidebar に live/reaped ドットを出す。
4405
+ const liveByThread = new Map()
4406
+ for (const s of ctx.codexBridge.describeLiveSessions(cwd)) {
4407
+ if (s.thread_id) liveByThread.set(s.thread_id, { live: true, busy: !!s.busy })
4408
+ }
4409
+ const withLive = sessions.map((t) => ({
4410
+ ...t,
4411
+ live: liveByThread.get(t.thread_id)?.live === true,
4412
+ busy: liveByThread.get(t.thread_id)?.busy === true,
4413
+ }))
4402
4414
  ctx.client.send({
4403
4415
  type: "codex.sessions.response",
4404
4416
  stream_id,
4405
4417
  cwd,
4406
- sessions,
4418
+ sessions: withLive,
4407
4419
  })
4408
4420
  } catch (err) {
4409
4421
  ctx.client.send({