@cocorograph/hub-agent 0.7.80 → 0.7.81
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 +1 -1
- package/src/claude-stream-bridge.mjs +168 -14
- package/src/codex-stream-bridge.mjs +111 -36
- package/src/main.mjs +8 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
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:
|
|
2610
|
+
stream_id: session.stream_id,
|
|
2577
2611
|
code: 0,
|
|
2578
2612
|
reason: "idle-reaped",
|
|
2579
|
-
session_id:
|
|
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
|
-
|
|
2617
|
-
|
|
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
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
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.
|
|
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
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
308
|
-
|
|
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
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
|
|
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
|
-
*
|
|
1464
|
-
*
|
|
1465
|
-
*
|
|
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
|
-
*
|
|
1510
|
-
*
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
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. **
|
|
1547
|
-
*
|
|
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
|
|
1576
|
-
reaped += this.
|
|
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
|
@@ -2072,6 +2072,14 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2072
2072
|
} catch (err) {
|
|
2073
2073
|
logger?.warn({ err: err?.message }, "gcDeadTerminals failed")
|
|
2074
2074
|
}
|
|
2075
|
+
// 子⑤ (T06544): claude 側 warm 境界(per-cwd=1 / N-cwd LRU)。従来 claude は per-session の
|
|
2076
|
+
// softDetach タイマー(7 日)だけで件数上限が無かったため、多数の cwd を渡り歩くと warm な
|
|
2077
|
+
// SDK 常駐 query が蓄積し得た。codex の reconcileSessions と対で cwd 単位に有界化する。
|
|
2078
|
+
try {
|
|
2079
|
+
claudeBridge?.reconcileWarmBounds?.()
|
|
2080
|
+
} catch (err) {
|
|
2081
|
+
logger?.warn({ err: err?.message }, "claude reconcileWarmBounds failed")
|
|
2082
|
+
}
|
|
2075
2083
|
// Codex 版 (claude の gcDeadTerminals と対): detach を送らず消えた / turn/completed が
|
|
2076
2084
|
// 来ず activeTurnId が固着したスタックセッションの app-server を回収する (プロセス
|
|
2077
2085
|
// リーク・生成中固着の根治)。進行中の正常ターンは app-server 通知で touch され残る。
|