@cocorograph/hub-agent 0.7.80 → 0.7.82

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.80",
3
+ "version": "0.7.82",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -145,6 +145,18 @@ function readNeedsAuthCacheNames() {
145
145
  const DEAD_TERMINAL_TTL_MS =
146
146
  Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) || 60 * 60 * 1000
147
147
 
148
+ /** 子⑤ (T06544): warm 保持を「各 cwd 1 本 × 同時 warm 保持する cwd 数 N(既定 20)」で有界化する
149
+ * 上限(codex-stream-bridge.mjs の WARM_CWD_MAX と対)。claude は従来 per-session の softDetach
150
+ * タイマー(7 日)だけで件数上限が無かったため、多数の cwd を渡り歩くと warm セッション(= SDK
151
+ * 常駐 query プロセス)が N 個を超えて蓄積し得た。cwd 単位の LRU で最古 cwd から早期 reap する。
152
+ * env HUB_AGENT_WARM_CWD_MAX で調整可能(codex と共有)。 */
153
+ const WARM_CWD_MAX = Number(process.env.HUB_AGENT_WARM_CWD_MAX) || 20
154
+
155
+ /** 子⑤ (T06544, codex-review round5 Finding 1): warm 境界の「最新 1 本を残す」判定で recency が
156
+ * 同一ミリ秒になったときのタイブレーク用の単調増加シーケンス(codex 側 CODEX_SESSION_SEQ と対)。
157
+ * 生成順が後 = 値が大きい = 新しいので、per-cwd=1 のタイでは必ず新しいセッションを残す。 */
158
+ let CLAUDE_SESSION_SEQ = 0
159
+
148
160
  /**
149
161
  * モデル能力レジストリ (2026-07-11): モデル名 → { supportsEffort, supportsXhighEffort }。
150
162
  *
@@ -332,6 +344,14 @@ class ClaudeStreamSession {
332
344
  * stream_id が streamIds/sessions に永久残留し、最後の 1 台が外れないと idle softDetach が
333
345
  * 起動しないため死端末がセッションを永久に生かしてしまう。 */
334
346
  this.lastActivityByStream = new Map([[stream_id, Date.now()]])
347
+ /** 子⑤ (T06544): session 単位の最終活性時刻。warm 境界(cwd-LRU)の recency 判定に使う。
348
+ * lastActivityByStream は detach で端末キーごと削除されるため warm セッションでは空になり
349
+ * recency を失う。これは touch() で更新し detach でリセットしないので、warm 化しても「最後に
350
+ * 使った時刻」が残り、最古 cwd から LRU reap できる。 */
351
+ this._lastActivityMs = Date.now()
352
+ /** 子⑤ round5 Finding 1: 生成順の単調増加シーケンス(warm 境界のタイブレーク用。値が大きい =
353
+ * 新しい)。recency 同値時に per-cwd=1 が新しいセッションを残せるようにする。 */
354
+ this._seq = CLAUDE_SESSION_SEQ += 1
335
355
  this.cwd = cwd
336
356
  this.model = model || null
337
357
  this.permissionMode = permissionMode || null
@@ -714,6 +734,9 @@ class ClaudeStreamSession {
714
734
 
715
735
  /** B11: 端末の最終アクティビティ時刻を更新する (死端末 GC の生存判定に使う)。 */
716
736
  touch(stream_id) {
737
+ // 子⑤: warm 境界の cwd-LRU 用に session 単位の最終活性も更新する(detach でリセットしないので
738
+ // warm 化しても最後の活性時刻が残る)。stream_id の購読有無に関わらず活性シグナルとして扱う。
739
+ this._lastActivityMs = Date.now()
717
740
  if (stream_id && this.streamIds.has(stream_id)) {
718
741
  this.lastActivityByStream.set(stream_id, Date.now())
719
742
  }
@@ -2569,17 +2592,27 @@ export class ClaudeStreamBridge extends EventEmitter {
2569
2592
  // 常駐化: browser 切断ではセッションを止めない (reap しない)。走行中ターンは完走し、
2570
2593
  // idle のまま IDLE_DETACH_TTL_MS 経過して初めて撤去する。TTL 内に再接続 (再アタッチ)
2571
2594
  // されればキャンセルされる。明示的に止めたい場合は interrupt()/明示終了を使う。
2572
- s.softDetach(IDLE_DETACH_TTL_MS, () => {
2573
- this._dropSessionMappings(s)
2574
- s.close()
2595
+ this._softDetachSession(s)
2596
+ return true
2597
+ }
2598
+
2599
+ /** 子⑤ round5 Finding 3: session を直接 soft-detach(idle TTL で reap)する共通ヘルパー。
2600
+ * detach() と gcDeadTerminals() の「全端末離脱 → warm 保持」の唯一の入口。
2601
+ * ⚠️ gcDeadTerminals は死端末の stream_id を先に全削除するため、`this.detach({stream_id})` を
2602
+ * 呼ぶと lookup が undefined を引いて no-op になり、session が「streamIds=0 / _detached=false /
2603
+ * idle timer 無し / _liveBySession だけに残る」孤児になっていた(20cwd 未満では warm-bounds でも
2604
+ * 回収されない)。stream_id lookup を介さず session を直接 soft-detach することで孤児化を防ぐ。 */
2605
+ _softDetachSession(session) {
2606
+ session.softDetach(IDLE_DETACH_TTL_MS, () => {
2607
+ this._dropSessionMappings(session)
2608
+ session.close()
2575
2609
  this.emit("exit", {
2576
- stream_id: s.stream_id,
2610
+ stream_id: session.stream_id,
2577
2611
  code: 0,
2578
2612
  reason: "idle-reaped",
2579
- session_id: s.sessionId,
2613
+ session_id: session.sessionId,
2580
2614
  })
2581
2615
  })
2582
- return true
2583
2616
  }
2584
2617
 
2585
2618
  /** B11: 多端末共有時に「死端末」(一定期間 input/permission 等の活性が無い購読端末) を
@@ -2613,23 +2646,144 @@ export class ClaudeStreamBridge extends EventEmitter {
2613
2646
  )
2614
2647
  }
2615
2648
  // 全端末が死端末で空になったら、idle softDetach 経路に乗せる (走行中は完走を待つ)。
2616
- if (session.streamIds.size === 0) {
2617
- this.detach({ stream_id: session.stream_id })
2649
+ // ⚠️ round5 Finding 3: 上のループで死端末キーを全削除済みのため this.detach({stream_id}) は
2650
+ // lookup 失敗で no-op になる。session を直接 soft-detach して孤児化を防ぐ。
2651
+ if (session.streamIds.size === 0 && !session._detached && !session._closed) {
2652
+ this._softDetachSession(session)
2618
2653
  }
2619
2654
  }
2620
2655
  return removed
2621
2656
  }
2622
2657
 
2658
+ /** 子⑤ (T06544): warm 境界(cwd-LRU)用の session 単位 recency。attached 端末があればその最新
2659
+ * 活性、warm(端末キーが無い softDetached 状態)なら session._lastActivityMs を使う。detach は
2660
+ * 端末キーごと lastActivityByStream を消すため、warm セッションはこの session 単位の値でしか
2661
+ * 「最後に使った時刻」を判定できない。 */
2662
+ _sessionRecency(session) {
2663
+ let at = session?._lastActivityMs ?? 0
2664
+ for (const t of session?.lastActivityByStream?.values?.() ?? []) {
2665
+ if (t > at) at = t
2666
+ }
2667
+ return at
2668
+ }
2669
+
2670
+ /** 子⑤ round5 Finding 2 対応: warm 境界で reap してはいけない「in-flight」セッション。
2671
+ * 走行中ターン(_busy)に加え、送信待ちの pending がある(現ターン完了時に drain される)
2672
+ * セッションも保護する(reap すると未処理入力が失われる)。 */
2673
+ _isSessionProtected(session) {
2674
+ if (!session) return false
2675
+ if (session._busy) return true
2676
+ return Array.isArray(session._pendingMessages) && session._pendingMessages.length > 0
2677
+ }
2678
+
2679
+ /** 子⑤: warm 境界超過セッションを reap する(detach の softDetach onTimeout と同じ撤去処理)。 */
2680
+ _reapForWarmBounds(session, reason) {
2681
+ if (!session || session._closed) return
2682
+ this._dropSessionMappings(session)
2683
+ session.close()
2684
+ this.emit("exit", {
2685
+ stream_id: session.stream_id,
2686
+ code: 0,
2687
+ reason: "idle-reaped",
2688
+ session_id: session.sessionId,
2689
+ })
2690
+ this.logger?.info(
2691
+ { stream_id: session.stream_id, session_id: session.sessionId, cwd: session.cwd, reason },
2692
+ "claude session reaped by warm bounds",
2693
+ )
2694
+ }
2695
+
2696
+ /**
2697
+ * 子⑤ (T06544): warm 保持を「各 cwd 1 本 × 同時 warm 保持する cwd 数 N(既定 20)」で有界化する
2698
+ * (codex-stream-bridge.mjs の `_reconcileWarmBounds` と対)。state loop から周期実行する。
2699
+ * Phase 1 (per-cwd=1): 同一 cwd に 2 本以上の live session があれば最終活性が最新の 1 本を残し、
2700
+ * 残りの **非 busy** を reap する(WS 再接続は購読者追加=同一 session なので通常は発火せず、
2701
+ * 別会話を同一 cwd で開始した等で 2 本目の SDK query ができたときだけ畳む)。
2702
+ * Phase 2 (N-cwd LRU): live session を持つ distinct cwd 数が上限を超えたら、cwd の最終活性が
2703
+ * 最も古い cwd から、その cwd の非 busy session を reap して cwd 数を上限まで戻す。
2704
+ * busy(走行中ターン)は Phase 1/2 いずれでも reap しない(完走を待つ)。warm セッションは
2705
+ * this.sessions から外れ _liveBySession に残るため、両索引の union を dedup して走査する。
2706
+ * @param {{ maxCwds?: number, now?: number }} [opts] テストから上限/時刻を注入可能。
2707
+ * @returns {number} reap 数
2708
+ */
2709
+ reconcileWarmBounds({ maxCwds = WARM_CWD_MAX, now = Date.now() } = {}) {
2710
+ void now // recency 比較は相対値のため now は将来の拡張用(署名の対称性維持)。
2711
+ const liveSessions = () =>
2712
+ Array.from(new Set([...this.sessions.values(), ...this._liveBySession.values()])).filter(
2713
+ (s) => s && !s._closed,
2714
+ )
2715
+ let reaped = 0
2716
+ // Phase 1: per-cwd=1。cwd ごとに最終活性が新しい順へ並べ、先頭(最新)以外の非 busy を reap。
2717
+ const byCwd = new Map()
2718
+ for (const s of liveSessions()) {
2719
+ const list = byCwd.get(s.cwd)
2720
+ if (list) list.push(s)
2721
+ else byCwd.set(s.cwd, [s])
2722
+ }
2723
+ for (const list of byCwd.values()) {
2724
+ if (list.length <= 1) continue
2725
+ // 新しい順。recency 同値は _seq(生成順)で必ず新しい方を先頭(= 残す)にする(round5 Finding 1)。
2726
+ list.sort(
2727
+ (a, b) =>
2728
+ this._sessionRecency(b) - this._sessionRecency(a) || (b._seq ?? 0) - (a._seq ?? 0),
2729
+ )
2730
+ for (let i = 1; i < list.length; i += 1) {
2731
+ if (this._isSessionProtected(list[i])) continue // 走行中/pending は不可侵(一時的に >1 を許容)
2732
+ this._reapForWarmBounds(list[i], "warm-bounds:per-cwd")
2733
+ reaped += 1
2734
+ }
2735
+ }
2736
+ // Phase 2: N-cwd LRU。per-cwd 畳み込み後の live から cwd 単位の recency / seq / protected 有無を集計。
2737
+ const cwdInfo = new Map() // cwd -> { recency, seq, hasProtected, sessions: [] }
2738
+ for (const s of liveSessions()) {
2739
+ let info = cwdInfo.get(s.cwd)
2740
+ if (!info) {
2741
+ info = { recency: -Infinity, seq: -Infinity, hasProtected: false, sessions: [] }
2742
+ cwdInfo.set(s.cwd, info)
2743
+ }
2744
+ const r = this._sessionRecency(s)
2745
+ if (r > info.recency) info.recency = r
2746
+ if ((s._seq ?? 0) > info.seq) info.seq = s._seq ?? 0 // round5 Finding 1: cwd タイブレーク用
2747
+ if (this._isSessionProtected(s)) info.hasProtected = true
2748
+ info.sessions.push(s)
2749
+ }
2750
+ const overflow = cwdInfo.size - maxCwds
2751
+ if (overflow > 0) {
2752
+ // protected(走行中/pending)を含む cwd は退避対象外(上限にカウントはするが reap しない)。
2753
+ // 最古 recency の cwd から。recency 同値は seq(新しさ)で必ず新しい cwd を残す。
2754
+ const evictableCwds = Array.from(cwdInfo.values())
2755
+ .filter((info) => !info.hasProtected)
2756
+ .sort((a, b) => a.recency - b.recency || a.seq - b.seq) // 古い順
2757
+ let cwdsEvicted = 0
2758
+ for (const info of evictableCwds) {
2759
+ if (cwdsEvicted >= overflow) break
2760
+ for (const s of info.sessions) {
2761
+ if (this._isSessionProtected(s)) continue
2762
+ this._reapForWarmBounds(s, "warm-bounds:cwd-lru")
2763
+ reaped += 1
2764
+ }
2765
+ cwdsEvicted += 1
2766
+ }
2767
+ }
2768
+ return reaped
2769
+ }
2770
+
2623
2771
  /** 全セッションを強制停止 (agent shutdown 用。実行中ターンも中断する)。 */
2624
2772
  shutdown() {
2625
- for (const stream_id of Array.from(this.sessions.keys())) {
2626
- const s = this.sessions.get(stream_id)
2627
- if (!s) continue
2773
+ // round6 Finding: this.sessions と _liveBySession の union を Set で dedup し、各 session を
2774
+ // 1 回だけ close/exit する。
2775
+ // ① soft-detach 後に _liveBySession だけに残る warm session も確実に close する
2776
+ // (旧実装は this.sessions のみ走査 → warm は索引 clear だけで close されず idle timer が漏れた)。
2777
+ // ② 多端末共有(1 session を複数 stream key が指す)で close/exit が端末数ぶん重複発火するのを防ぐ。
2778
+ const seen = new Set()
2779
+ for (const s of [...this.sessions.values(), ...this._liveBySession.values()]) {
2780
+ if (!s || seen.has(s)) continue
2781
+ seen.add(s)
2628
2782
  s.close()
2629
- this.sessions.delete(stream_id)
2630
- this.emit("exit", { stream_id, code: 0, reason: "shutdown", session_id: s.sessionId })
2783
+ this.emit("exit", { stream_id: s.stream_id, code: 0, reason: "shutdown", session_id: s.sessionId })
2631
2784
  }
2632
- // 常駐化: 再アタッチ索引も全消去 (プロセス終了時の後始末)。
2785
+ // stream key 索引・再アタッチ索引を全消去 (プロセス終了時の後始末)。
2786
+ this.sessions.clear()
2633
2787
  this._liveBySession.clear()
2634
2788
  // MCP control 専用 query も畳む。
2635
2789
  this._teardownMcpControlQuery()
@@ -88,10 +88,21 @@ function siblingEnvFor(cwd) {
88
88
  const IDLE_DETACH_TTL_MS =
89
89
  Number(process.env.HUB_AGENT_CODEX_IDLE_DETACH_TTL_MS) || 7 * 24 * 60 * 60 * 1000
90
90
 
91
- // LRU warm pool の上限(マシン全体で同時に生かす codex セッション数)。超過分は最も使って
92
- // いない非 protected セッションから reap。1 セッション ≈ 120-140MB(node ラッパー + 本体 +
93
- // MCP 子)なので既定 5(~650MB)。env で調整可。
94
- const WARM_POOL_MAX = Number(process.env.HUB_AGENT_CODEX_WARM_POOL_MAX) || 5
91
+ // 子⑤ (T06544): warm 保持の上限を「マシン全体の総セッション数」から「各 cwd 1 本 × 同時 warm
92
+ // 保持する cwd 数 N」へ再定義(親 T06539「1 cwd 1 本化」に整合)。旧 WARM_POOL_MAX(総数 LRU=5)は
93
+ // 廃止し、境界を cwd 単位で定義する。1 セッション ≈ 120-140MB(node ラッパー + 本体 + MCP 子)
94
+ // なので既定 20 cwd(~2.4-2.8GB 上限)。端末リソースで無理なら env で下げる。
95
+ // 後方互換: 旧 HUB_AGENT_CODEX_WARM_POOL_MAX を設定していても新 cwd 上限として解釈する。
96
+ const WARM_CWD_MAX =
97
+ Number(process.env.HUB_AGENT_WARM_CWD_MAX) ||
98
+ Number(process.env.HUB_AGENT_CODEX_WARM_POOL_MAX) ||
99
+ 20
100
+
101
+ // 子⑤ (T06544, codex-review round5 Finding 1): warm 境界の「最新 1 本を残す」判定で recency
102
+ // (lastActivityMs) が同一ミリ秒になったときのタイブレーク用の単調増加シーケンス。生成順が後 =
103
+ // 値が大きい = 新しいので、per-cwd=1 のタイでは **必ず新しく attach したセッションを残す**
104
+ // (旧実装は安定ソートの挿入順で旧セッションが残り、新規 attach が即 reap される非決定性があった)。
105
+ let CODEX_SESSION_SEQ = 0
95
106
 
96
107
  // stuck turn 検出の閾値。activeTurnId が立っているのに app-server 通知がこの時間途絶えた
97
108
  // セッションは「ハングしたターン」とみなし protected から外して reap する(healthy な長ターンは
@@ -290,7 +301,7 @@ export class CodexStreamBridge extends EventEmitter {
290
301
  logger,
291
302
  clientFactory,
292
303
  detachTtlMs,
293
- warmPoolMax,
304
+ warmCwdMax,
294
305
  stuckTurnTtlMs,
295
306
  deadTerminalTtlMs,
296
307
  turnStartTimeoutMs,
@@ -304,8 +315,9 @@ export class CodexStreamBridge extends EventEmitter {
304
315
  this._clientFactory = clientFactory || ((opts) => new CodexAppServerClient(opts))
305
316
  // Stage 5: warm 保持の idle TTL(既定 7 日)。テストから短縮できるよう注入可能。
306
317
  this._detachTtlMs = detachTtlMs ?? IDLE_DETACH_TTL_MS
307
- // Stage 5: LRU warm pool 上限(既定 5)。テストから小さい値を注入可能。
308
- this._warmPoolMax = warmPoolMax ?? WARM_POOL_MAX
318
+ // 子⑤ (T06544): 同時 warm 保持する cwd 数の上限(既定 20)。各 cwd は 1 本に畳む。
319
+ // テストから小さい値を注入可能。
320
+ this._warmCwdMax = warmCwdMax ?? WARM_CWD_MAX
309
321
  // Stage 5: stuck turn 検出 TTL(既定 1h)。テストから短縮可能。
310
322
  this._stuckTurnTtlMs = stuckTurnTtlMs ?? STUCK_TURN_TTL_MS
311
323
  // 案B (T06542): 死端末 GC の TTL(既定 1h)。テストから短縮可能。
@@ -409,6 +421,9 @@ export class CodexStreamBridge extends EventEmitter {
409
421
  // primary stream_id: legacy な単一値 emit / ログ用。案B では最新購読端末へ寄せる
410
422
  // (streamIds が真の購読者集合)。app-server 通知や emit は都度 session.stream_id を読む。
411
423
  stream_id,
424
+ // 子⑤ round5 Finding 1: 生成順の単調増加シーケンス(warm 境界のタイブレーク用。値が大きい =
425
+ // 新しい)。recency 同値時に per-cwd=1 が新しいセッションを残せるようにする。
426
+ seq: (CODEX_SESSION_SEQ += 1),
412
427
  cwd,
413
428
  // 案B (T06542): 多端末購読集合。この live session を購読中の全端末 stream_id。reattach は
414
429
  // 「所有権付け替え」ではなく「購読者追加」。detach で当該端末を外し、最後の1台が外れたら
@@ -545,10 +560,11 @@ export class CodexStreamBridge extends EventEmitter {
545
560
  },
546
561
  "codex session attached",
547
562
  )
548
- // Stage 5: genuine attach で 1 プロセス増えたので、LRU 上限の超過分を即時退避する
549
- // (次の reconnect まで ~5s 待たず、6 個目の app-server が居座るのを防ぐ)。新セッションは
550
- // たった今 touch 済み = LRU 先頭なので退避対象にならず、最も古い非 protected が落ちる。
551
- this._evictWarmPoolOverflow(Date.now(), "lru-evict-on-attach")
563
+ // 子⑤ (T06544): genuine attach で 1 本増えたので warm 境界(per-cwd=1 / N-cwd LRU)を即時
564
+ // 適用する(次の reconnect まで ~5s 待たず、同一 cwd の重複や cwd 数超過が居座るのを防ぐ)。
565
+ // 新セッションはたった今 touch 済み = 各判定で最新なので退避対象にならず、最も古い非 protected
566
+ // が落ちる。
567
+ this._reconcileWarmBounds(Date.now(), "warm-bounds-on-attach")
552
568
  return {
553
569
  resuming,
554
570
  threadId: session.threadId,
@@ -1459,15 +1475,26 @@ export class CodexStreamBridge extends EventEmitter {
1459
1475
  }
1460
1476
 
1461
1477
  /**
1462
- * Stage 5: セッションが「保護対象(reap 不可)」か。= **healthy な running turn**。
1463
- * activeTurnId が立っており、かつ app-server 通知が STUCK_TTL 以内に届いている(=生きている
1464
- * ターン)。activeTurnId が固着したまま通知が STUCK_TTL 途絶えたもの(stuck/ハング)は
1465
- * protected ではなく reap 対象にする(プロセスリーク・固着の救済)。
1478
+ * Stage 5: セッションが「保護対象(reap 不可)」か。= **healthy な running turn**、
1479
+ * または **in-flight な入力処理中**(codex-review round5 Finding 2 対応)。
1480
+ * - in-flight: 単一 pump が走行中(`pumping`)/未処理 FIFO が残っている(`inputQueue`)/
1481
+ * turn/start RPC 送信後に turn/started を待っている(`_turnActiveWaiter`)状態。この間は
1482
+ * `activeTurnId` が未確定(null)でもターンが実際に始まりかけているので、per-cwd / N-cwd /
1483
+ * idle-TTL いずれでも reap してはいけない(別 attach が Phase 1 を発火した瞬間に処理中
1484
+ * セッションを殺す事故を防ぐ)。pump は turn/start timeout で必ず有限時間に畳まれるため、
1485
+ * ここで保護してもハング検出(stuck-turn)が効かなくなることはない。
1486
+ * - healthy running turn: `activeTurnId` が立ち、app-server 通知が STUCK_TTL 以内に届いている。
1487
+ * activeTurnId 固着で通知が STUCK_TTL 途絶えたもの(stuck/ハング)は protected から外し reap
1488
+ * 対象にする(プロセスリーク・固着の救済)。
1466
1489
  * @param {object} session
1467
1490
  * @param {number} now
1468
1491
  * @returns {boolean}
1469
1492
  */
1470
1493
  _isProtected(session, now) {
1494
+ // in-flight 入力処理中(turn/start RPC 応答待ち等、activeTurnId 未確定でも処理中)を保護。
1495
+ if (session.pumping) return true
1496
+ if (Array.isArray(session.inputQueue) && session.inputQueue.length > 0) return true
1497
+ if (session._turnActiveWaiter) return true
1471
1498
  if (!session.activeTurnId) return false
1472
1499
  return now - (session.lastActivityMs ?? now) <= this._stuckTurnTtlMs
1473
1500
  }
@@ -1506,26 +1533,74 @@ export class CodexStreamBridge extends EventEmitter {
1506
1533
  }
1507
1534
 
1508
1535
  /**
1509
- * Stage 5: LRU warm pool 上限の超過分を退避する。生存(非 dead)セッションが上限を超えたら、
1510
- * **非 protected の中で最終活性が最も古いもの**から順に上限まで reap する。protected(healthy
1511
- * running turn)は退避対象にしないため、全部が busy のときは一時的に上限を超えたまま許容する
1512
- * (走行中ターンを絶対に殺さない)。genuine attach 直後と reconcile の両方から呼ぶ。
1536
+ * 子⑤ (T06544): warm 保持を「各 cwd 1 本 × 同時 warm 保持する cwd 数 N(既定 20)」で有界化する。
1537
+ * 旧 `_evictWarmPoolOverflow`(マシン全体の総セッション数 LRU=5)を置換し、境界を「総数」ではなく
1538
+ * 「cwd 単位」で定義する(親 T06539「1 cwd 1 本化」に整合)。
1539
+ * Phase 1 (per-cwd=1): 同一 cwd に 2 本以上の live session があれば、最終活性が最新の 1 本を残し
1540
+ * 残りの **非 protected** を reap する(WS 再接続や別スレッド開始で一時的に増えた重複を畳む)。
1541
+ * Phase 2 (N-cwd LRU): live session を持つ distinct cwd 数が warmCwdMax を超えたら、cwd の最終活性
1542
+ * が最も古い cwd から、その cwd の非 protected session を reap して cwd 数を上限まで戻す。
1543
+ * protected(healthy running turn / stuck 未満)は Phase 1/2 いずれでも reap しない(busy 不可侵)。
1544
+ * 全 cwd が busy 等で上限に収められないときは一時的に超過を許容する(走行中ターンを絶対に殺さない)。
1545
+ * 多端末購読は 1 session に集約されるため(_liveSessions で dedup)、purely な多端末利用では
1546
+ * per-cwd=1 は自動的に満たされ、Phase 1 は「同一 cwd に別 app-server が 2 本以上できたとき」だけ発火する。
1547
+ * genuine attach 直後と reconcile の両方から呼ぶ。
1513
1548
  * @param {number} now
1514
- * @returns {number} 退避した数
1549
+ * @returns {number} reap した数
1515
1550
  */
1516
- _evictWarmPoolOverflow(now, reason = "lru-evict") {
1517
- // 案B: warm pool 上限は「distinct な生存 session 数」で判定する(購読端末数ではない)。
1518
- const live = this._liveSessions()
1519
- const overflow = live.length - this._warmPoolMax
1520
- if (overflow <= 0) return 0
1521
- const evictable = live
1522
- .filter((s) => !this._isProtected(s, now))
1523
- .sort((a, b) => (a.lastActivityMs ?? 0) - (b.lastActivityMs ?? 0)) // 古い順
1551
+ _reconcileWarmBounds(now, reason = "warm-bounds") {
1524
1552
  let evicted = 0
1525
- for (const s of evictable) {
1526
- if (evicted >= overflow) break
1527
- this._reapSession(s, now, reason)
1528
- evicted += 1
1553
+ // Phase 1: per-cwd=1。cwd ごとに最終活性が新しい順へ並べ、先頭(最新)以外の非 protected を reap。
1554
+ const byCwd = new Map()
1555
+ for (const s of this._liveSessions()) {
1556
+ const list = byCwd.get(s.cwd)
1557
+ if (list) list.push(s)
1558
+ else byCwd.set(s.cwd, [s])
1559
+ }
1560
+ for (const list of byCwd.values()) {
1561
+ if (list.length <= 1) continue
1562
+ // 新しい順。recency 同値は seq(生成順)で必ず新しい方を先頭(= 残す)にする(round5 Finding 1)。
1563
+ list.sort(
1564
+ (a, b) =>
1565
+ (b.lastActivityMs ?? 0) - (a.lastActivityMs ?? 0) || (b.seq ?? 0) - (a.seq ?? 0),
1566
+ )
1567
+ for (let i = 1; i < list.length; i += 1) {
1568
+ if (this._isProtected(list[i], now)) continue // busy は残す(一時的に >1 を許容)
1569
+ this._reapSession(list[i], now, `${reason}:per-cwd`)
1570
+ evicted += 1
1571
+ }
1572
+ }
1573
+ // Phase 2: N-cwd LRU。per-cwd 畳み込み後の live から cwd 単位の recency と protected 有無を集計。
1574
+ const cwdInfo = new Map() // cwd -> { recency, seq, hasProtected, sessions: [] }
1575
+ for (const s of this._liveSessions()) {
1576
+ let info = cwdInfo.get(s.cwd)
1577
+ if (!info) {
1578
+ info = { recency: -Infinity, seq: -Infinity, hasProtected: false, sessions: [] }
1579
+ cwdInfo.set(s.cwd, info)
1580
+ }
1581
+ info.recency = Math.max(info.recency, s.lastActivityMs ?? 0)
1582
+ info.seq = Math.max(info.seq, s.seq ?? 0) // round5 Finding 1: recency 同値の cwd タイブレーク用
1583
+ if (this._isProtected(s, now)) info.hasProtected = true
1584
+ info.sessions.push(s)
1585
+ }
1586
+ const overflow = cwdInfo.size - this._warmCwdMax
1587
+ if (overflow > 0) {
1588
+ // busy を含む cwd は退避対象外(上限にカウントはするが reap しない)。最古 recency の cwd から。
1589
+ // recency 同値は seq(新しさ)で必ず新しい cwd を残す(古い方から evict)。
1590
+ const evictableCwds = Array.from(cwdInfo.values())
1591
+ .filter((info) => !info.hasProtected)
1592
+ .sort((a, b) => a.recency - b.recency || a.seq - b.seq) // 古い順
1593
+ let cwdsEvicted = 0
1594
+ for (const info of evictableCwds) {
1595
+ if (cwdsEvicted >= overflow) break
1596
+ for (const s of info.sessions) {
1597
+ // per-cwd 畳み込み後は各 cwd 1 本のはずだが、念のため非 protected のみ reap。
1598
+ if (this._isProtected(s, now)) continue
1599
+ this._reapSession(s, now, `${reason}:cwd-lru`)
1600
+ evicted += 1
1601
+ }
1602
+ cwdsEvicted += 1
1603
+ }
1529
1604
  }
1530
1605
  return evicted
1531
1606
  }
@@ -1543,8 +1618,8 @@ export class CodexStreamBridge extends EventEmitter {
1543
1618
  * 1. **stuck turn**: activeTurnId 固着だが app-server 通知が STUCK_TTL 途絶えた(ハング)→ reap
1544
1619
  * (healthy な長ターンは reasoning/output delta で touch されるため該当しない)。
1545
1620
  * 2. **long idle**: 非 busy が idle TTL(既定 7 日)無活性 → reap(warm 保持の期限切れ)。
1546
- * 3. **LRU 上限**: 生存数が warmPoolMax(既定 5)を超えたら、非 protected の最終活性が
1547
- * 最も古いものから上限まで reap。
1621
+ * 3. **warm 境界**(子⑤ T06544): `_reconcileWarmBounds` で「各 cwd 1 本 × 同時 warm cwd 数 N
1622
+ * (既定 20)」に有界化。同一 cwd の重複は最新 1 本へ畳み、cwd 数超過は最古 cwd から reap。
1548
1623
  * busy(healthy running turn)は 1〜3 いずれの対象にもならない(走行中ターンを絶対に殺さない)。
1549
1624
  *
1550
1625
  * 旧 `gcAbandonedSessions`(1h 無活性一律 reap)を置換。detach 済み warm セッションを 1h で
@@ -1572,8 +1647,8 @@ export class CodexStreamBridge extends EventEmitter {
1572
1647
  reaped += 1
1573
1648
  }
1574
1649
  }
1575
- // 3: LRU 上限の超過分を退避(stuck/idle reap 後の生存数で判定)。
1576
- reaped += this._evictWarmPoolOverflow(now, "lru-evict")
1650
+ // 3: warm 境界(per-cwd=1 / N-cwd LRU)を適用(stuck/idle reap 後の生存数で判定)。
1651
+ reaped += this._reconcileWarmBounds(now, "warm-bounds")
1577
1652
  return reaped
1578
1653
  }
1579
1654
 
package/src/main.mjs CHANGED
@@ -1515,6 +1515,230 @@ export function _resetContextWindowLearning() {
1515
1515
  lastAssistantModelByChatKey.clear()
1516
1516
  }
1517
1517
 
1518
+ /**
1519
+ * session_event の「サイドバー並び順に効くか (sort_affecting)」判定用の per-session 状態。
1520
+ * 「その session-run で実ユーザーターン (prompt_submit) を見たか」を持つ。
1521
+ *
1522
+ * ## なぜ必要か (2026-07-20 実測で確定した現象B の根治)
1523
+ * 死んだ/寝ているセッションを開く=hub-agent が claude を respawn/resume すると、その
1524
+ * 起動・resume の落ち着きで Stop フック (`stop`) や readiness (`ready`)、SessionStart
1525
+ * (`session_start`) が near-now で発火する。frontend は session.event を種別問わず
1526
+ * eventAt の sort key に使うため、ユーザーが 1 ターンも回していないのに「開いた/起こした
1527
+ * だけ」でサイドバー最上位へ浮上していた。
1528
+ *
1529
+ * 対処療法 (フロントで focus 直後の bump を数秒抑制) ではなく、発火源で「ソート信号」と
1530
+ * 「状態信号」を分離する: **並び順は実ユーザーターン (prompt_submit と、それに続く
1531
+ * stop/ready) だけで動かす**。ライフサイクル (session_start/session_end) と、先行する
1532
+ * prompt_submit の無い stop/ready (=起動/resume の落ち着き) は sort_affecting=false とし、
1533
+ * frontend は status ドット用途にのみ使い eventAt は動かさない。
1534
+ */
1535
+ const sawUserTurnByName = new Map()
1536
+
1537
+ // session 単位で「最後に sort_affecting=true だった event の at」を保持する。
1538
+ // event ファイルは 1 session 1 ファイルの上書きで、最新が sort_affecting=false
1539
+ // (ready 等) だと直前の実ターン時刻が失われる。これを別途保持し poll (ハードリロード
1540
+ // 復元) で配信することで、リロード後に created 順へ退行する劣化を防ぐ (codex-review major#1)。
1541
+ const lastSortAffectingAtByName = new Map()
1542
+
1543
+ // session 単位で「最後に処理した event の at」。fs.watch の pushFile は session 単位で
1544
+ // 直列化されず、古い prompt_submit の readFile が新しい session_start の後に完了して
1545
+ // sawUserTurn=true を復活させうる (out-of-order)。at 単調ガードで stale な状態更新を弾く
1546
+ // (codex-review major#2)。
1547
+ const lastEventAtByName = new Map()
1548
+
1549
+ /**
1550
+ * session-event の種別 (と「実ユーザーターンを見たか」) から sort_affecting を導出する純関数。
1551
+ * - prompt_submit: 常に true (ユーザーがターンを開始した)
1552
+ * - session_start / session_end: 常に false (ライフサイクル、ターンではない)
1553
+ * - stop / ready / idle_hint: 直前に prompt_submit があった (sawUserTurn) ときだけ true
1554
+ * (=起動/resume の落ち着きで出た stop/ready は false)
1555
+ * - 未知種別: 後方互換で true (並び順に効かせる)
1556
+ * @param {string} event
1557
+ * @param {boolean} sawUserTurn
1558
+ * @returns {boolean}
1559
+ */
1560
+ export function computeSortAffecting(event, sawUserTurn) {
1561
+ if (event === "prompt_submit") return true
1562
+ if (event === "session_start" || event === "session_end") return false
1563
+ if (event === "stop" || event === "ready" || event === "idle_hint") {
1564
+ return sawUserTurn === true
1565
+ }
1566
+ return true
1567
+ }
1568
+
1569
+ /**
1570
+ * session-event 種別に応じて sawUserTurnByName を更新する。
1571
+ * - prompt_submit: 実ユーザーターン開始 → true
1572
+ * - session_start / session_end: session-run の境界 → false にリセット
1573
+ * (次に実 prompt_submit を見るまで stop/ready は sort_affecting=false)
1574
+ * その他の種別ではフラグを変えない (stop/ready は直前の prompt_submit 状態を引き継ぐ)。
1575
+ * @param {string} name tmux session 名
1576
+ * @param {string} event
1577
+ */
1578
+ export function updateSawUserTurn(name, event) {
1579
+ if (!name) return
1580
+ if (event === "prompt_submit") sawUserTurnByName.set(name, true)
1581
+ else if (event === "session_start" || event === "session_end") {
1582
+ sawUserTurnByName.set(name, false)
1583
+ }
1584
+ }
1585
+
1586
+ /**
1587
+ * session-event 1 件を sort_affecting 状態機械に取り込む。at 単調ガードで out-of-order な
1588
+ * stale read を弾き (major#2)、sort_affecting を導出し、true なら sort 時刻を更新する (major#1)。
1589
+ * @param {string} name tmux session 名
1590
+ * @param {string} event 種別
1591
+ * @param {number} at epoch ms
1592
+ * @returns {{ sortAffecting: boolean, sortAffectingAt: number|undefined, stale: boolean }}
1593
+ */
1594
+ export function ingestSessionEventForSort(name, event, at) {
1595
+ const prevAt = lastEventAtByName.get(name) ?? 0
1596
+ // stale (この session で処理済みの at より古い) read は状態を汚さない。arm や status は
1597
+ // 呼び出し側で従来通り処理してよいが、sort へは一切効かせない。
1598
+ if (typeof at === "number" && at < prevAt) {
1599
+ return {
1600
+ sortAffecting: false,
1601
+ sortAffectingAt: lastSortAffectingAtByName.get(name),
1602
+ stale: true,
1603
+ }
1604
+ }
1605
+ if (typeof at === "number") lastEventAtByName.set(name, at)
1606
+ updateSawUserTurn(name, event)
1607
+ const sortAffecting = computeSortAffecting(event, sawUserTurnByName.get(name) === true)
1608
+ // sort_affecting=true で前進したときだけ sort 時刻を更新し、sidecar に永続化する (major#1)。
1609
+ // event ファイルは揮発 (最新イベントで上書き) のため、再起動後に「最後の実ターン時刻」を
1610
+ // 復元できるよう別ファイルに残す。冪等な再取込 (at 同値) では書かない。
1611
+ if (sortAffecting && typeof at === "number" && at > (lastSortAffectingAtByName.get(name) ?? 0)) {
1612
+ lastSortAffectingAtByName.set(name, at)
1613
+ writeSortAtSidecar(name, at) // fire-and-forget (エラーは握りつぶす)
1614
+ }
1615
+ return {
1616
+ sortAffecting,
1617
+ sortAffectingAt: lastSortAffectingAtByName.get(name),
1618
+ stale: false,
1619
+ }
1620
+ }
1621
+
1622
+ /** poll (readAllSessionEvents) 用: この session の「最後に sort に効いた at」を返す (無ければ undefined)。 */
1623
+ export function getSortAffectingAt(name) {
1624
+ return lastSortAffectingAtByName.get(name)
1625
+ }
1626
+
1627
+ // sort 時刻 sidecar の接尾辞。`.json` にしない (readAllSessionEvents / pushFile が
1628
+ // `.endsWith(".json")` で event ファイルと誤認しないように)。
1629
+ const SORT_AT_SUFFIX = ".sortat"
1630
+
1631
+ export function sortAtSidecarPath(name) {
1632
+ return path.join(SESSION_EVENTS_DIR, `${name}${SORT_AT_SUFFIX}`)
1633
+ }
1634
+
1635
+ // sidecar の write / delete を **session 単位で直列化** する op キュー。write と delete が
1636
+ // 双方 fire-and-forget だと「実ターン直後に kill → delete が先に走り (ENOENT)、その後 write の
1637
+ // rename が dead sidecar を復活」する競合が起きる (codex-review minor)。enqueue 順 = 実行順を
1638
+ // 保証すれば、ingest(write) → forget(delete) の順で delete が後になり sidecar は確実に消える。
1639
+ const sidecarOpTailByName = new Map()
1640
+
1641
+ function enqueueSidecarOp(name, op) {
1642
+ const prev = sidecarOpTailByName.get(name) ?? Promise.resolve()
1643
+ const next = prev.then(op, op).catch(() => {})
1644
+ sidecarOpTailByName.set(name, next)
1645
+ // 末尾が settle したら Map エントリを掃除 (自分が最新末尾のときだけ; リーク防止)。
1646
+ next.finally(() => {
1647
+ if (sidecarOpTailByName.get(name) === next) sidecarOpTailByName.delete(name)
1648
+ })
1649
+ return next
1650
+ }
1651
+
1652
+ /**
1653
+ * 「最後の実ターン時刻」を sidecar に atomic 書き出す (major#1 の再起動永続化)。
1654
+ * fire-and-forget だが session 単位で直列化する。event ファイルと別ファイルなので
1655
+ * Python hook の上書きとは競合しない。
1656
+ */
1657
+ export function writeSortAtSidecar(name, at) {
1658
+ if (!name || /[/\\]/.test(name) || typeof at !== "number") return Promise.resolve()
1659
+ return enqueueSidecarOp(name, async () => {
1660
+ const fp = sortAtSidecarPath(name)
1661
+ const tmp = `${fp}.tmp.${randomUUID()}`
1662
+ try {
1663
+ await mkdir(SESSION_EVENTS_DIR, { recursive: true })
1664
+ await writeFile(tmp, JSON.stringify({ sort_at: at }))
1665
+ await rename(tmp, fp)
1666
+ } catch {
1667
+ try {
1668
+ await unlink(tmp)
1669
+ } catch {
1670
+ /* ignore */
1671
+ }
1672
+ }
1673
+ })
1674
+ }
1675
+
1676
+ /**
1677
+ * forget 時に sidecar を消す (死名が再起動シードで復活しないように; codex-review minor)。
1678
+ * write と同じ session 単位キューに載せ、先行 write の後に必ず delete が走るようにする。
1679
+ */
1680
+ export function deleteSortAtSidecar(name) {
1681
+ if (!name || /[/\\]/.test(name)) return Promise.resolve()
1682
+ return enqueueSidecarOp(name, async () => {
1683
+ try {
1684
+ await unlink(sortAtSidecarPath(name))
1685
+ } catch {
1686
+ /* 無ければ無視 */
1687
+ }
1688
+ })
1689
+ }
1690
+
1691
+ /**
1692
+ * 起動時シード: sidecar から「最後の実ターン時刻」を復元する。揮発 event ファイルではなく
1693
+ * sidecar を正本にすることで、最新イベントが session_start/resume 由来 (sort_affecting=false)
1694
+ * でもその near-now at で現象B を再発させない (codex-review major#1 の再репро対策)。
1695
+ */
1696
+ export async function seedSortAtFromSidecars() {
1697
+ let files
1698
+ try {
1699
+ files = await readdir(SESSION_EVENTS_DIR)
1700
+ } catch {
1701
+ return
1702
+ }
1703
+ for (const f of files) {
1704
+ if (!f.endsWith(SORT_AT_SUFFIX)) continue
1705
+ const name = f.slice(0, -SORT_AT_SUFFIX.length)
1706
+ try {
1707
+ const data = JSON.parse(await readFile(path.join(SESSION_EVENTS_DIR, f), "utf-8"))
1708
+ if (data && typeof data.sort_at === "number") seedSortAffectingAt(name, data.sort_at)
1709
+ } catch {
1710
+ /* 壊れた sidecar は無視 */
1711
+ }
1712
+ }
1713
+ }
1714
+
1715
+ /**
1716
+ * sort 時刻の初期値を設定する (sidecar シード専用)。既存値は上書きしない。
1717
+ * @param {string} name
1718
+ * @param {number} at
1719
+ */
1720
+ export function seedSortAffectingAt(name, at) {
1721
+ if (!name || typeof at !== "number") return
1722
+ if (!lastSortAffectingAtByName.has(name)) lastSortAffectingAtByName.set(name, at)
1723
+ if ((lastEventAtByName.get(name) ?? 0) < at) lastEventAtByName.set(name, at)
1724
+ }
1725
+
1726
+ /** session 終了 (session.gone / kill / GC) 時に sort_affecting 状態を破棄する (リーク防止)。 */
1727
+ export function forgetSortAffectingState(name) {
1728
+ if (!name) return
1729
+ sawUserTurnByName.delete(name)
1730
+ lastSortAffectingAtByName.delete(name)
1731
+ lastEventAtByName.delete(name)
1732
+ deleteSortAtSidecar(name) // 永続 sidecar も消す (再起動シードでの死名復活防止)
1733
+ }
1734
+
1735
+ /** テスト用: sort_affecting 状態を全リセットする。 */
1736
+ export function _resetSawUserTurn() {
1737
+ sawUserTurnByName.clear()
1738
+ lastSortAffectingAtByName.clear()
1739
+ lastEventAtByName.clear()
1740
+ }
1741
+
1518
1742
  /**
1519
1743
  * sort 用の session-event ファイルをチャットのターン境界で書き出す
1520
1744
  * (`/tmp/cockpit_session_events/<tmux名>.json`)。bundle hook が TUI で書くものと
@@ -1565,6 +1789,15 @@ async function readAllSessionEvents() {
1565
1789
  out.set(sessionName, {
1566
1790
  event: data.event,
1567
1791
  at: typeof data.at === "number" ? data.at : Date.now(),
1792
+ // ポーリング経路 (ハードリロード時の eventAt 復元) でも push 経路と同じ
1793
+ // sort_affecting を frontend へ伝える。map (watcher が最新化) を参照する。
1794
+ sort_affecting: computeSortAffecting(
1795
+ data.event,
1796
+ sawUserTurnByName.get(sessionName) === true,
1797
+ ),
1798
+ // major#1: 最新 event が sort_affecting=false でも、直前の実ターン時刻を
1799
+ // frontend へ渡して created 順への退行を防ぐ。frontend はこの値で eventAt を復元する。
1800
+ sort_affecting_at: getSortAffectingAt(sessionName),
1568
1801
  })
1569
1802
  } catch {
1570
1803
  // 一時欠如 / parse 失敗は無視
@@ -1641,6 +1874,11 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
1641
1874
  readinessTracker.arm(sessionName)
1642
1875
  }
1643
1876
  }
1877
+ // sort_affecting 判定を状態機械へ委譲する: at 単調ガードで out-of-order な stale read を
1878
+ // 弾き (major#2)、prompt_submit/session_start で状態遷移し、sort_affecting=true なら
1879
+ // sort 時刻を更新する (major#1)。arm や status push は従来通り (stale でも status は最新化)。
1880
+ const at = typeof data.at === "number" ? data.at : Date.now()
1881
+ const { sortAffecting } = ingestSessionEventForSort(sessionName, data.event, at)
1644
1882
  // 計装 (2026-06-19, 既定 OFF=2026-06-20): ターン境界イベントと arm を記録する。常時 info は
1645
1883
  // ログ肥大の主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す(障害解析時に有効化)。
1646
1884
  if (STATE_TRACE) {
@@ -1649,6 +1887,7 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
1649
1887
  session_name: sessionName,
1650
1888
  event: data.event,
1651
1889
  armed: readinessTracker ? readinessTracker.isArmed(sessionName) : undefined,
1890
+ sort_affecting: sortAffecting,
1652
1891
  },
1653
1892
  "session.event push",
1654
1893
  )
@@ -1657,13 +1896,20 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
1657
1896
  type: "session.event",
1658
1897
  session_name: sessionName,
1659
1898
  event: data.event,
1660
- at: typeof data.at === "number" ? data.at : Date.now(),
1899
+ at,
1900
+ // false=ライフサイクル/resume 落ち着き由来 → frontend は status のみ更新し並び順は動かさない。
1901
+ sort_affecting: sortAffecting,
1661
1902
  })
1662
1903
  } catch {
1663
1904
  // 一時欠如 / parse 失敗等は無視 (次の変更で読み直す)
1664
1905
  }
1665
1906
  }
1666
1907
 
1908
+ // major#1 再起動ケア: pushFile より前に、sidecar から「最後の実ターン時刻」をシードする。
1909
+ // 揮発 event ファイル (最新イベントで上書き) からはシードしない。最新が session_start/resume
1910
+ // 由来 (sort_affecting=false) の near-now at をシードすると現象B が再発するため (codex-review)。
1911
+ await seedSortAtFromSidecars()
1912
+
1667
1913
  // 起動時に既存ファイルを 1 回読んで初期 push (再起動後に取りこぼし防止)
1668
1914
  try {
1669
1915
  const files = await readdir(SESSION_EVENTS_DIR)
@@ -2072,6 +2318,14 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2072
2318
  } catch (err) {
2073
2319
  logger?.warn({ err: err?.message }, "gcDeadTerminals failed")
2074
2320
  }
2321
+ // 子⑤ (T06544): claude 側 warm 境界(per-cwd=1 / N-cwd LRU)。従来 claude は per-session の
2322
+ // softDetach タイマー(7 日)だけで件数上限が無かったため、多数の cwd を渡り歩くと warm な
2323
+ // SDK 常駐 query が蓄積し得た。codex の reconcileSessions と対で cwd 単位に有界化する。
2324
+ try {
2325
+ claudeBridge?.reconcileWarmBounds?.()
2326
+ } catch (err) {
2327
+ logger?.warn({ err: err?.message }, "claude reconcileWarmBounds failed")
2328
+ }
2075
2329
  // Codex 版 (claude の gcDeadTerminals と対): detach を送らず消えた / turn/completed が
2076
2330
  // 来ず activeTurnId が固着したスタックセッションの app-server を回収する (プロセス
2077
2331
  // リーク・生成中固着の根治)。進行中の正常ターンは app-server 通知で touch され残る。
@@ -2335,6 +2589,7 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2335
2589
  invalidateSessionCache(name) // capture/cwd/statusGate/spinnerFreeze/stability (state.mjs)
2336
2590
  stallTracker.forget(name) // StallTracker.byName
2337
2591
  readinessTracker?.forget?.(name) // ReadinessTracker.byName
2592
+ forgetSortAffectingState(name) // sawUserTurn / sort_affecting_at / lastEventAt (リーク防止)
2338
2593
  lastByName.delete(name)
2339
2594
  outputFlowByName.delete(name)
2340
2595
  lastTurnAtByName.delete(name)
@@ -2889,6 +3144,7 @@ export async function handleKillSession(msg, ctx) {
2889
3144
  } catch {
2890
3145
  /* ignore */
2891
3146
  }
3147
+ forgetSortAffectingState(name) // sort_affecting 状態のリーク防止 (kill 経路)
2892
3148
  }
2893
3149
  }
2894
3150
 
@@ -3893,6 +4149,9 @@ async function dispatch(msg, ctx) {
3893
4149
  event: lastEvent.event,
3894
4150
  at: lastEvent.at,
3895
4151
  replay: true,
4152
+ // この再配信は bind(=フォーカス) 契機の status 復旧専用。並び順は絶対に
4153
+ // 動かさない (フォーカスで浮上させない現象B 根治の一部)。
4154
+ sort_affecting: false,
3896
4155
  })
3897
4156
  }
3898
4157
  }