@cocorograph/hub-agent 0.7.79 → 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 +208 -55
- package/src/codex-stream-bridge.mjs +509 -115
- package/src/main.mjs +31 -4
- package/src/service-install.mjs +5 -37
- package/templates/co.cocorograph.hub-agent.plist +1 -1
- package/templates/hub-agent.service +1 -1
package/package.json
CHANGED
|
@@ -122,20 +122,20 @@ function readNeedsAuthCacheNames() {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
/** 多端末共有セッション (2026-05-30)。同じ Claude セッション
|
|
126
|
-
*
|
|
127
|
-
* HUB_AGENT_CHAT_SHARED="1" で有効化 (デフォルト無効 = 従来の「最後の端末だけが live」)。
|
|
125
|
+
/** 多端末共有セッション (2026-05-30、T06542 で既定 ON 化・フラグ撤去)。同じ Claude セッション
|
|
126
|
+
* (session_id) を複数端末で同時に開いて、どの端末でもライブ表示・入力・許可応答できる。
|
|
128
127
|
*
|
|
129
|
-
*
|
|
130
|
-
* - reattach で旧 stream_id を sessions Map から消さず、1 セッションに複数の端末
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* - detach は購読端末の参照カウントを下げ、最後の 1 台が外れて初めて idle softDetach
|
|
134
|
-
*
|
|
135
|
-
* 配信のファンアウト自体は Hub backend が session_id 単位の group broadcast で行うため、
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
|
|
128
|
+
* 挙動 (常時):
|
|
129
|
+
* - reattach で旧 stream_id を sessions Map から消さず、1 セッションに複数の端末 stream_id を
|
|
130
|
+
* 購読登録する (this.streamIds)。これにより旧端末からの input / permission.reply / interrupt
|
|
131
|
+
* も sessions.get(stream_id) で解決できる。
|
|
132
|
+
* - detach は購読端末の参照カウントを下げ、最後の 1 台が外れて初めて idle softDetach する
|
|
133
|
+
* (1 台閉じただけで走行中セッションを撤去しない)。
|
|
134
|
+
* 配信のファンアウト自体は Hub backend が session_id 単位の group broadcast で行うため、agent 側は
|
|
135
|
+
* イベントに session_id を載せるだけでよい (既に emit 済み)。
|
|
136
|
+
*
|
|
137
|
+
* ⚠️ 旧 env `HUB_AGENT_CHAT_SHARED` は撤去した (T06542)。以前フラグ OFF だと「最後の端末だけ
|
|
138
|
+
* live」に退行し、環境間で挙動が割れる事故源だったため、単一挙動へ一本化した (Codex 側 案B と対称)。 */
|
|
139
139
|
|
|
140
140
|
/** B11: 多端末共有時、一定期間 input/permission 等の活性が無い購読端末キーを死端末とみなして
|
|
141
141
|
* GC する閾値 (ミリ秒)。端末がクラッシュして claude.detach を送らず消えると streamIds /
|
|
@@ -145,6 +145,18 @@ const CHAT_SHARED_ENABLED = process.env.HUB_AGENT_CHAT_SHARED === "1"
|
|
|
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
|
*
|
|
@@ -322,16 +334,24 @@ class ClaudeStreamSession {
|
|
|
322
334
|
onTurnSettled,
|
|
323
335
|
}) {
|
|
324
336
|
this.stream_id = stream_id
|
|
325
|
-
/**
|
|
326
|
-
*
|
|
337
|
+
/** 多端末共有 (T06542 で常時): このセッションを購読中の全端末 stream_id。reattach で増え
|
|
338
|
+
* detach で減る。最後の 1 台が外れると idle softDetach に入る。
|
|
327
339
|
* this.stream_id は「最も新しく attach した端末」= legacy emit / primary 用に残す。 */
|
|
328
340
|
this.streamIds = new Set([stream_id])
|
|
329
341
|
/** B11: 購読端末ごとの最終アクティビティ時刻 (epoch ms)。attach / reattach / input /
|
|
330
|
-
* permission
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
342
|
+
* permission 応答で更新する。「死端末 GC」(gcDeadTerminals) が参照し、一定期間 無活動の
|
|
343
|
+
* 購読端末キーを掃除する。これが無いと端末がクラッシュして claude.detach を送らず消えると
|
|
344
|
+
* stream_id が streamIds/sessions に永久残留し、最後の 1 台が外れないと idle softDetach が
|
|
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
|
}
|
|
@@ -1866,12 +1889,9 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1866
1889
|
// ユニークだが異常系でも cwd を跨がない)。
|
|
1867
1890
|
const live = this._liveBySession.get(this._liveKeyFor(scwd, resumeSessionId))
|
|
1868
1891
|
if (live && !live._closed) {
|
|
1869
|
-
// 多端末共有 (
|
|
1870
|
-
//
|
|
1871
|
-
//
|
|
1872
|
-
if (!CHAT_SHARED_ENABLED) {
|
|
1873
|
-
this.sessions.delete(live.stream_id)
|
|
1874
|
-
}
|
|
1892
|
+
// 多端末共有 (T06542 で常時): 旧 stream_id を Map から消さない。同一セッションを複数端末が
|
|
1893
|
+
// 購読する状態を作り、どの端末の input/permission/interrupt も解決できるようにする
|
|
1894
|
+
// (旧端末は reattach.streamIds に足され、backend が session group へ fan-out する)。
|
|
1875
1895
|
// 改修5: 再アタッチ時に model/permission/maxThinkingTokens を引き継ぎ反映する。
|
|
1876
1896
|
// browser はバッジ変更時に新しい値を載せた claude.attach を同一 resume で送る
|
|
1877
1897
|
// ため、ここで適用すると常駐 query を維持したまま次ターンから切り替わる。
|
|
@@ -2052,6 +2072,11 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2052
2072
|
this.logger?.warn({ stream_id, request_id }, "claude.permission.reply but stream missing")
|
|
2053
2073
|
return false
|
|
2054
2074
|
}
|
|
2075
|
+
// T06542 バグ修正: 許可応答も端末の活性シグナル。touch を呼ばないと「入力せず許可応答だけを
|
|
2076
|
+
// 返し続ける端末」が lastActivityByStream 未更新のまま gcDeadTerminals(1h) に死端末として掃除され、
|
|
2077
|
+
// 以降その端末からの入力/応答が stream_missing になる (session 定義コメント「permission 応答で
|
|
2078
|
+
// 更新する」と実装が不一致だった)。多端末共有を常時 ON 化すると顕在化するためここで直す。
|
|
2079
|
+
s.touch(stream_id)
|
|
2055
2080
|
const decision = allow
|
|
2056
2081
|
? { behavior: "allow", updatedInput: updatedInput || {} }
|
|
2057
2082
|
: { behavior: "deny", message: denyMessage || "denied by user" }
|
|
@@ -2550,46 +2575,53 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2550
2575
|
detach({ stream_id }) {
|
|
2551
2576
|
const s = this.sessions.get(stream_id)
|
|
2552
2577
|
if (!s) return false
|
|
2553
|
-
//
|
|
2554
|
-
// softDetach (idle 撤去タイマー) には入らない。最後の 1 台が外れたときだけ従来の
|
|
2555
|
-
// idle softDetach
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
s.stream_id = Array.from(s.streamIds).at(-1)
|
|
2565
|
-
}
|
|
2566
|
-
return true
|
|
2578
|
+
// 多端末共有 (T06542 で常時): この端末分の購読だけ外す。まだ他端末が見ているならセッションは
|
|
2579
|
+
// 生かし、softDetach (idle 撤去タイマー) には入らない。最後の 1 台が外れたときだけ従来の
|
|
2580
|
+
// idle softDetach に進む。
|
|
2581
|
+
s.streamIds.delete(stream_id)
|
|
2582
|
+
s.lastActivityByStream.delete(stream_id) // B11: 活性記録も掃除
|
|
2583
|
+
if (this.sessions.get(stream_id) === s) this.sessions.delete(stream_id)
|
|
2584
|
+
if (s.streamIds.size > 0) {
|
|
2585
|
+
// primary を生存端末へ寄せ替え (legacy emit / softDetach 撤去ログ用)。
|
|
2586
|
+
// 末尾 (最も新しく購読した端末) を .at(-1) で明示的に選ぶ。
|
|
2587
|
+
if (s.stream_id === stream_id) {
|
|
2588
|
+
s.stream_id = Array.from(s.streamIds).at(-1)
|
|
2567
2589
|
}
|
|
2590
|
+
return true
|
|
2568
2591
|
}
|
|
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 等の活性が無い購読端末) を
|
|
2586
2619
|
* GC する。端末がクラッシュして claude.detach を送らず消えた stream_id を掃除し、最後の
|
|
2587
2620
|
* 1 台が外れたら通常の idle softDetach (走行中は完走を待つ) に進ませる。state loop など
|
|
2588
|
-
*
|
|
2621
|
+
* 既存の定期ループから呼ぶ想定 (T06542 で常時有効)。
|
|
2589
2622
|
* @param {number} [ttlMs] 死端末とみなす無活性閾値 (テスト用に上書き可)。
|
|
2590
2623
|
* @returns {number} GC した端末キー数。 */
|
|
2591
2624
|
gcDeadTerminals(ttlMs = DEAD_TERMINAL_TTL_MS) {
|
|
2592
|
-
if (!CHAT_SHARED_ENABLED) return 0
|
|
2593
2625
|
const now = Date.now()
|
|
2594
2626
|
let removed = 0
|
|
2595
2627
|
for (const session of new Set(this.sessions.values())) {
|
|
@@ -2614,23 +2646,144 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2614
2646
|
)
|
|
2615
2647
|
}
|
|
2616
2648
|
// 全端末が死端末で空になったら、idle softDetach 経路に乗せる (走行中は完走を待つ)。
|
|
2617
|
-
|
|
2618
|
-
|
|
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)
|
|
2619
2653
|
}
|
|
2620
2654
|
}
|
|
2621
2655
|
return removed
|
|
2622
2656
|
}
|
|
2623
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
|
+
|
|
2624
2771
|
/** 全セッションを強制停止 (agent shutdown 用。実行中ターンも中断する)。 */
|
|
2625
2772
|
shutdown() {
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
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)
|
|
2629
2782
|
s.close()
|
|
2630
|
-
this.
|
|
2631
|
-
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 })
|
|
2632
2784
|
}
|
|
2633
|
-
//
|
|
2785
|
+
// stream key 索引・再アタッチ索引を全消去 (プロセス終了時の後始末)。
|
|
2786
|
+
this.sessions.clear()
|
|
2634
2787
|
this._liveBySession.clear()
|
|
2635
2788
|
// MCP control 専用 query も畳む。
|
|
2636
2789
|
this._teardownMcpControlQuery()
|