@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 +1 -1
- package/src/claude-stream-bridge.mjs +168 -14
- package/src/codex-stream-bridge.mjs +111 -36
- package/src/main.mjs +260 -1
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
|
@@ -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
|
|
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
|
}
|