@cocorograph/hub-agent 0.8.22 → 0.8.24
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 +262 -27
- package/src/codex-stream-bridge.mjs +309 -88
- package/src/main.mjs +22 -2
- package/src/positive-int-env.mjs +81 -0
- package/src/session-seq.mjs +22 -0
- package/src/sibling-inject.mjs +149 -15
- package/src/warm-bounds-total.mjs +150 -0
|
@@ -52,6 +52,8 @@ import {
|
|
|
52
52
|
verifyCodexDeliveryAudit,
|
|
53
53
|
} from "./codex-delivery-audit.mjs"
|
|
54
54
|
import { getOrCreateSiblingToken } from "./sibling-token.mjs"
|
|
55
|
+
import { readPositiveIntEnv, sanitizePositiveInt } from "./positive-int-env.mjs"
|
|
56
|
+
import { nextSessionSeq } from "./session-seq.mjs"
|
|
55
57
|
import { codexRolloutPresence, listCodexThreadsFromDisk } from "./usage.mjs"
|
|
56
58
|
|
|
57
59
|
/**
|
|
@@ -92,49 +94,90 @@ function siblingEnvFor(cwd) {
|
|
|
92
94
|
// これにより意味論は「**閲覧中=無期限(keepalive で LRU 先頭維持)/ 直近 5 セッションは
|
|
93
95
|
// detach しても 7 日 warm / 上限超過は LRU で 1 個 reap / busy は不可侵 / stuck は救済**」。
|
|
94
96
|
|
|
97
|
+
// ⚠️ 以下の上限 / TTL 系 env は **すべて readPositiveIntEnv を通す**(positive-int-env.mjs)。
|
|
98
|
+
// 素の `Number(env) || 既定` だと "1.5" や "Infinity" が通り、上限が for の開始添字に入ると
|
|
99
|
+
// `list[1.5]` が undefined になって `_isProtected(undefined)` で TypeError になる。
|
|
100
|
+
// 既定値と意味は従来のまま。不正値は既定へ戻して warn する(黙って丸めない)。
|
|
101
|
+
|
|
95
102
|
// warm 保持の idle TTL(非 busy がこの時間 無活性なら reap の対象。上限内でも 7 日で頭打ち)。
|
|
96
|
-
const IDLE_DETACH_TTL_MS =
|
|
97
|
-
|
|
103
|
+
const IDLE_DETACH_TTL_MS = readPositiveIntEnv(
|
|
104
|
+
"HUB_AGENT_CODEX_IDLE_DETACH_TTL_MS",
|
|
105
|
+
7 * 24 * 60 * 60 * 1000,
|
|
106
|
+
)
|
|
98
107
|
|
|
99
|
-
// 子⑤ (T06544): warm 保持の上限を「マシン全体の総セッション数」から「各 cwd
|
|
108
|
+
// 子⑤ (T06544): warm 保持の上限を「マシン全体の総セッション数」から「各 cwd K 本 × 同時 warm
|
|
100
109
|
// 保持する cwd 数 N」へ再定義(親 T06539「1 cwd 1 本化」に整合)。旧 WARM_POOL_MAX(総数 LRU=5)は
|
|
101
110
|
// 廃止し、境界を cwd 単位で定義する。1 セッション ≈ 120-140MB(node ラッパー + 本体 + MCP 子)
|
|
102
111
|
// なので既定 20 cwd(~2.4-2.8GB 上限)。端末リソースで無理なら env で下げる。
|
|
103
112
|
// 後方互換: 旧 HUB_AGENT_CODEX_WARM_POOL_MAX を設定していても新 cwd 上限として解釈する。
|
|
104
|
-
const WARM_CWD_MAX =
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
113
|
+
const WARM_CWD_MAX = readPositiveIntEnv(
|
|
114
|
+
["HUB_AGENT_WARM_CWD_MAX", "HUB_AGENT_CODEX_WARM_POOL_MAX"],
|
|
115
|
+
20,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
// 1 cwd あたりの live セッション上限 K(既定 3)。旧仕様の per-cwd=1 を一般化したもので、
|
|
119
|
+
// 「1 cwd で複数会話を同時に生かす」ため。総量は増やさず配分の自由度だけ上げる方針なので、
|
|
120
|
+
// 総 live 上限 M(warm-bounds-total.mjs、claude と合算)とセットで効かせる。
|
|
121
|
+
// ⚠️ 既存 env(HUB_AGENT_WARM_CWD_MAX / HUB_AGENT_CODEX_WARM_POOL_MAX = 同時 warm 保持する
|
|
122
|
+
// cwd 数 N)の意味は変えない。新しい上限は新しい env 名で足す。
|
|
123
|
+
const WARM_PER_CWD_MAX = readPositiveIntEnv("HUB_AGENT_WARM_PER_CWD_MAX", 3)
|
|
114
124
|
|
|
115
125
|
// stuck turn 検出の閾値。activeTurnId が立っているのに app-server 通知がこの時間途絶えた
|
|
116
126
|
// セッションは「ハングしたターン」とみなし protected から外して reap する(healthy な長ターンは
|
|
117
127
|
// reasoning/output delta で都度 touch されるため該当しない)。旧 `HUB_AGENT_DEAD_TERMINAL_TTL_MS`
|
|
118
128
|
// を後方互換で受ける。既定 1h。
|
|
119
|
-
const STUCK_TURN_TTL_MS =
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
129
|
+
const STUCK_TURN_TTL_MS = readPositiveIntEnv(
|
|
130
|
+
["HUB_AGENT_CODEX_STUCK_TURN_TTL_MS", "HUB_AGENT_DEAD_TERMINAL_TTL_MS"],
|
|
131
|
+
60 * 60 * 1000,
|
|
132
|
+
)
|
|
123
133
|
|
|
124
134
|
// 案B (T06542): 死端末 GC の TTL。多端末購読で、応答を返さなくなった(クラッシュ / タブ強制終了で
|
|
125
135
|
// codex.detach を送れなかった)購読端末を購読集合から掃除する閾値。claude-stream-bridge.mjs の
|
|
126
136
|
// DEAD_TERMINAL_TTL_MS(既定 1h)と同型・同既定。stuck turn TTL とは別軸(あちらはターン固着、
|
|
127
137
|
// こちらは端末の無応答)だが既定は揃える。
|
|
128
|
-
const DEAD_TERMINAL_TTL_MS =
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
138
|
+
const DEAD_TERMINAL_TTL_MS = readPositiveIntEnv(
|
|
139
|
+
["HUB_AGENT_CODEX_DEAD_TERMINAL_TTL_MS", "HUB_AGENT_DEAD_TERMINAL_TTL_MS"],
|
|
140
|
+
60 * 60 * 1000,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
// 実行中の背景コマンド(commandExecution)を warm 境界の刈り取りから保護する失効時間(既定 4 時間)。
|
|
144
|
+
//
|
|
145
|
+
// 【なぜ必要か(旧「意図的な非対称」の訂正)】codex-cli 0.145.0 の実測
|
|
146
|
+
// (/tmp/satoru_bgprobe/raw2.jsonl)で、`item/started(commandExecution, status=inProgress)` →
|
|
147
|
+
// `turn/completed` の順に届いたあと **そのコマンドはまだ走っている**(sleep 120 で確認)。
|
|
148
|
+
// つまり activeTurnId(= busy)だけでは実行中の背景コマンドを守れず、claude 側の _liveTasks と
|
|
149
|
+
// 同型の台帳が要る。自然終了でも `item/completed` は届く(sleep 20 の追試で turn/completed の
|
|
150
|
+
// 17 秒後に status=completed。/tmp/satoru_bgprobe/raw4.jsonl)ので、通常は終了で解ける。
|
|
151
|
+
//
|
|
152
|
+
// 【なぜ claude の local_bash と同じ長さか】codex の `item/commandExecution/outputDelta`(鼓動)は
|
|
153
|
+
// **コマンド依存で来ないことがある**(出力の無いコマンドでは 1 件も来ない)。鼓動が保証されない
|
|
154
|
+
// 種別は長い方に揃える、という claude 側と同じ方針。
|
|
155
|
+
//
|
|
156
|
+
// ⚠️ これは**仕様上の最大保護時間**であり、**4 時間を超える背景コマンドは保護されない**。
|
|
157
|
+
//
|
|
158
|
+
// 【有界なのは「時間」だけで、「件数」ではない】(cross-review round2 minor)
|
|
159
|
+
// 1 エントリあたりの保護時間は TTL で有界(4 時間で頭打ち)。一方で **同時に保護されうるセッション
|
|
160
|
+
// 数には K(per-cwd)も N(cwd 数)も M(総 live 上限、warm-bounds-total.mjs)も効かない**。すべての
|
|
161
|
+
// 境界が protected を skip する設計なので、4 時間以内に別セッションで背景コマンドを増やせば、保護中の
|
|
162
|
+
// 合計は M を超えて増え続ける。これは「走行中を絶対に殺さない」を優先した**明示的な超過受容**であり、
|
|
163
|
+
// **M をハードな資源上限と読んではいけない**。超過量は warm-bounds-total.mjs が返す
|
|
164
|
+
// `liveTotal` / `unresolved` のログで観測できる(運用判断はその実測値を見る)。
|
|
165
|
+
//
|
|
166
|
+
// ⚠️ 将来ハード上限(保護中でも刈る)を足すなら、**実行中コマンドを中断するという新しい失敗モード**
|
|
167
|
+
// が生まれる。この TTL 設計の延長で暗黙に足さないこと。足すなら victim の選び方・利用者への通知・
|
|
168
|
+
// 再開可否を別仕様として設計する。
|
|
169
|
+
const CODEX_EXEC_PROTECT_TTL_MS = readPositiveIntEnv(
|
|
170
|
+
"HUB_AGENT_CODEX_EXEC_PROTECT_TTL_MS",
|
|
171
|
+
4 * 60 * 60 * 1000,
|
|
172
|
+
)
|
|
132
173
|
|
|
133
174
|
// 案B (T06542, codex-review round3 major#2): turn/start RPC 成功後に turn/started 通知が来るのを待つ
|
|
134
175
|
// 上限。これを超えたら「ターンが正しく始まらなかった」とみなし、pump が待機中の queue を reject して
|
|
135
176
|
// 状態を解除する(turn/started/completed/exit が来ないケースで promise が永久宙吊りになるのを防ぐ)。
|
|
136
|
-
const TURN_START_TIMEOUT_MS =
|
|
137
|
-
|
|
177
|
+
const TURN_START_TIMEOUT_MS = readPositiveIntEnv(
|
|
178
|
+
"HUB_AGENT_CODEX_TURN_START_TIMEOUT_MS",
|
|
179
|
+
30 * 1000,
|
|
180
|
+
)
|
|
138
181
|
|
|
139
182
|
// browser が ack を取りこぼしても queue snapshot から受理済みと再確認できるよう、
|
|
140
183
|
// 直近 input_id を bounded ring で保持する (Claude queue_state.accepted と同じ契約)。
|
|
@@ -143,8 +186,10 @@ const ACCEPTED_INPUT_ID_RING_MAX = 256
|
|
|
143
186
|
// sibling-inject の turn RPC が成功した後、app-server が同じ userMessage を
|
|
144
187
|
// item/completed で echo するまで待つ上限。RPC accepted と userMessage delivered を
|
|
145
188
|
// 分離し、旧 stream / 初期化競合で rollout に残らない入力を成功扱いしない。
|
|
146
|
-
const DELIVERY_ECHO_TIMEOUT_MS =
|
|
147
|
-
|
|
189
|
+
const DELIVERY_ECHO_TIMEOUT_MS = readPositiveIntEnv(
|
|
190
|
+
"HUB_AGENT_CODEX_DELIVERY_ECHO_TIMEOUT_MS",
|
|
191
|
+
8 * 1000,
|
|
192
|
+
)
|
|
148
193
|
|
|
149
194
|
const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
|
|
150
195
|
|
|
@@ -158,9 +203,11 @@ const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0
|
|
|
158
203
|
// が起きる。thread/resume 自体はモデル側の会話コンテキスト復元のため全量が必要だが、
|
|
159
204
|
// **browser へ渡す表示用履歴は末尾 N ターン分に絞る** ことで断崖を回避する。上限は
|
|
160
205
|
// 「item 数」と「概算バイト数」の 2 軸で、末尾から詰めてどちらか先に達したら truncated。
|
|
161
|
-
const HISTORY_MAX_ITEMS =
|
|
162
|
-
const HISTORY_MAX_BYTES =
|
|
163
|
-
|
|
206
|
+
const HISTORY_MAX_ITEMS = readPositiveIntEnv("HUB_AGENT_CODEX_HISTORY_MAX_ITEMS", 200)
|
|
207
|
+
const HISTORY_MAX_BYTES = readPositiveIntEnv(
|
|
208
|
+
"HUB_AGENT_CODEX_HISTORY_MAX_BYTES",
|
|
209
|
+
1.5 * 1024 * 1024,
|
|
210
|
+
)
|
|
164
211
|
|
|
165
212
|
// Slice 6: thread/resume が返す Thread.turns[].items[] を単一の時系列配列へ平坦化する。
|
|
166
213
|
// turns は開始ターン順の配列、各 turn.items も時系列順のため単純結合で会話順を保てる。
|
|
@@ -318,6 +365,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
318
365
|
clientFactory,
|
|
319
366
|
detachTtlMs,
|
|
320
367
|
warmCwdMax,
|
|
368
|
+
warmPerCwdMax,
|
|
321
369
|
stuckTurnTtlMs,
|
|
322
370
|
deadTerminalTtlMs,
|
|
323
371
|
turnStartTimeoutMs,
|
|
@@ -337,9 +385,19 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
337
385
|
this._clientFactory = clientFactory || ((opts) => new CodexAppServerClient(opts))
|
|
338
386
|
// Stage 5: warm 保持の idle TTL(既定 7 日)。テストから短縮できるよう注入可能。
|
|
339
387
|
this._detachTtlMs = detachTtlMs ?? IDLE_DETACH_TTL_MS
|
|
340
|
-
// 子⑤ (T06544): 同時 warm 保持する cwd
|
|
341
|
-
//
|
|
342
|
-
|
|
388
|
+
// 子⑤ (T06544): 同時 warm 保持する cwd 数の上限 N(既定 20)。各 cwd は K 本に畳む。
|
|
389
|
+
// テストから小さい値を注入可能。⚠️ 正の整数以外(1.5 / Infinity / NaN)は既定へ戻して warn する。
|
|
390
|
+
// 1.5 が for の開始添字に入ると `list[1.5]` が undefined になり `_isProtected(undefined)` で
|
|
391
|
+
// TypeError(positive-int-env.mjs 参照)。
|
|
392
|
+
this._warmCwdMax = sanitizePositiveInt(warmCwdMax, WARM_CWD_MAX, {
|
|
393
|
+
logger: this.logger,
|
|
394
|
+
label: "codex.warmCwdMax",
|
|
395
|
+
})
|
|
396
|
+
// 1 cwd あたりの live セッション上限 K(既定 3)。テストから 1 を注入すると旧 per-cwd=1 挙動。
|
|
397
|
+
this._warmPerCwdMax = sanitizePositiveInt(warmPerCwdMax, WARM_PER_CWD_MAX, {
|
|
398
|
+
logger: this.logger,
|
|
399
|
+
label: "codex.warmPerCwdMax",
|
|
400
|
+
})
|
|
343
401
|
// Stage 5: stuck turn 検出 TTL(既定 1h)。テストから短縮可能。
|
|
344
402
|
this._stuckTurnTtlMs = stuckTurnTtlMs ?? STUCK_TURN_TTL_MS
|
|
345
403
|
// 案B (T06542): 死端末 GC の TTL(既定 1h)。テストから短縮可能。
|
|
@@ -542,8 +600,10 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
542
600
|
// (streamIds が真の購読者集合)。app-server 通知や emit は都度 session.stream_id を読む。
|
|
543
601
|
stream_id,
|
|
544
602
|
// 子⑤ round5 Finding 1: 生成順の単調増加シーケンス(warm 境界のタイブレーク用。値が大きい =
|
|
545
|
-
// 新しい)。recency 同値時に per-cwd=
|
|
546
|
-
seq
|
|
603
|
+
// 新しい)。recency 同値時に per-cwd=K が新しいセッションを残せるようにする。
|
|
604
|
+
// ⚠️ カウンタは **claude / codex 共通**(session-seq.mjs)。総 live 上限 M は両 CLI を合算して
|
|
605
|
+
// 古い順に刈るため、CLI ごとに別カウンタだと CLI をまたいだ比較が成立しない。
|
|
606
|
+
seq: nextSessionSeq(),
|
|
547
607
|
cwd,
|
|
548
608
|
// 案B (T06542): 多端末購読集合。この live session を購読中の全端末 stream_id。reattach は
|
|
549
609
|
// 「所有権付け替え」ではなく「購読者追加」。detach で当該端末を外し、最後の1台が外れたら
|
|
@@ -626,6 +686,24 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
626
686
|
* deliveryId: string, sourceStreamId: string, deliveryPending: boolean,
|
|
627
687
|
* deliveryPromise: Promise<object>, settleDelivery: Function}>} */
|
|
628
688
|
pendingInjectSources: [],
|
|
689
|
+
/** 実行中の背景コマンド(commandExecution)の台帳。claude 側 `_liveTasks` と同型。
|
|
690
|
+
* key = `item.id`(`item/started` / `item/completed` の両方に載る唯一の安定 ID)、
|
|
691
|
+
* value = { threadId, startedAt, lastSeenAt }。
|
|
692
|
+
*
|
|
693
|
+
* ⚠️ `turn/completed` の後もコマンドは走り続ける(実測 /tmp/satoru_bgprobe/raw2.jsonl)ので、
|
|
694
|
+
* activeTurnId(busy)だけでは守れない。この台帳で `_isProtected` を延長する。
|
|
695
|
+
*
|
|
696
|
+
* ⚠️ **コマンド本文(`item.command`)は保持しない**。台帳に載せると失効 warn 等のログ経由で
|
|
697
|
+
* `~/.hub/agent.log`(launchd の StandardOutPath で 0644 のまま長期保存される)へ本文が流れ、
|
|
698
|
+
* Bearer トークン・API キー・個人情報が引数や heredoc ごと永続化される。会話 UI に流れること
|
|
699
|
+
* (認可された閲覧者だけが見る)と、永続ログに長期保存されることは境界が違う。
|
|
700
|
+
* ⚠️ ハッシュ化で代替しないこと。コマンド行はエントロピーが低く辞書で復元できるため、
|
|
701
|
+
* 「本文を出さない」の代わりにならない。診断は item_id / thread_id で辿る。
|
|
702
|
+
*
|
|
703
|
+
* ⚠️ threadId は**記録するだけ**で判定には使わない(帰属ガードは本 PR のスコープ外)。
|
|
704
|
+
* @type {Map<string, {threadId: string|null,
|
|
705
|
+
* startedAt: number, lastSeenAt: number}>} */
|
|
706
|
+
liveExecItems: new Map(),
|
|
629
707
|
}
|
|
630
708
|
session.ready = new Promise((resolve) => {
|
|
631
709
|
settleReady = resolve
|
|
@@ -752,8 +830,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
752
830
|
total_ms: Date.now() - attachStartedAt,
|
|
753
831
|
timing,
|
|
754
832
|
})
|
|
755
|
-
// 子⑤ (T06544): genuine attach で 1 本増えたので warm 境界(per-cwd=
|
|
756
|
-
// 適用する(次の reconnect まで ~5s
|
|
833
|
+
// 子⑤ (T06544): genuine attach で 1 本増えたので warm 境界(per-cwd=K / N-cwd LRU)を即時
|
|
834
|
+
// 適用する(次の reconnect まで ~5s 待たず、cwd 内の K 本超過や cwd 数超過が居座るのを防ぐ)。
|
|
757
835
|
// 新セッションはたった今 touch 済み = 各判定で最新なので退避対象にならず、最も古い非 protected
|
|
758
836
|
// が落ちる。
|
|
759
837
|
this._reconcileWarmBounds(Date.now(), "warm-bounds-on-attach")
|
|
@@ -1345,12 +1423,111 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1345
1423
|
if (this.sessions.get(session.stream_id) === session) this.sessions.delete(session.stream_id)
|
|
1346
1424
|
}
|
|
1347
1425
|
|
|
1426
|
+
/**
|
|
1427
|
+
* app-server 通知を観測して、実行中の背景コマンド台帳(`session.liveExecItems`)を更新する。
|
|
1428
|
+
*
|
|
1429
|
+
* 呼び出しは `_onNotification` の先頭 1 箇所だけ(claude 側 `_observeTaskEvent` と同じ理由で、
|
|
1430
|
+
* switch の中に散らさない)。実測(codex-cli 0.145.0 / /tmp/satoru_bgprobe/raw2.jsonl, raw4.jsonl)
|
|
1431
|
+
* に基づく扱い:
|
|
1432
|
+
* - `item/started` + `item.type === "commandExecution"` + `item.status === "inProgress"` → 登録
|
|
1433
|
+
* - `item/completed` + `item.type === "commandExecution"` → `item.id` で削除(冪等)
|
|
1434
|
+
* - `item/commandExecution/outputDelta`(`params.itemId`)→ **既存エントリがあるときだけ**延命。
|
|
1435
|
+
* 新規作成しない(完了後に遅れて届いた delta で蘇らせない。claude の task_progress と同じ)
|
|
1436
|
+
*
|
|
1437
|
+
* ⚠️ `item.type` であって `item_type` ではない(実測のフィールド名)。
|
|
1438
|
+
* ⚠️ 対象は `commandExecution` に限る。`mcpToolCall` も長時間化しうるが「turn/completed の後も
|
|
1439
|
+
* 走り続けるか」を**実測していない**ため含めない(未実測のものを保護に入れると、逆に
|
|
1440
|
+
* 解除されない保護を作りかねない)。実測できたら同じ台帳に足せばよい。
|
|
1441
|
+
* ⚠️ 例外を外へ投げないこと。`_onNotification` は全通知が通るので、ここで throw すると
|
|
1442
|
+
* ストリーム全体が壊れる。
|
|
1443
|
+
*
|
|
1444
|
+
* @param {object} session
|
|
1445
|
+
* @param {object} msg app-server 通知({ method, params })
|
|
1446
|
+
* @param {number} [now] 時刻(テストから注入可能)
|
|
1447
|
+
*/
|
|
1448
|
+
_observeExecItem(session, msg, now = Date.now()) {
|
|
1449
|
+
try {
|
|
1450
|
+
const method = msg?.method
|
|
1451
|
+
if (typeof method !== "string" || !method) return
|
|
1452
|
+
const ledger = session?.liveExecItems
|
|
1453
|
+
if (!ledger) return
|
|
1454
|
+
const params = msg.params
|
|
1455
|
+
if (method === "item/started" || method === "item/completed") {
|
|
1456
|
+
const item = params?.item
|
|
1457
|
+
if (!item || item.type !== "commandExecution") return
|
|
1458
|
+
const id = item.id
|
|
1459
|
+
if (typeof id !== "string" || !id) return
|
|
1460
|
+
if (method === "item/completed") {
|
|
1461
|
+
ledger.delete(id)
|
|
1462
|
+
return
|
|
1463
|
+
}
|
|
1464
|
+
if (item.status !== "inProgress") return
|
|
1465
|
+
ledger.set(id, {
|
|
1466
|
+
// 帰属判定には使わない(記録のみ)。別件で子スレッド起因を切り分けたくなったとき用。
|
|
1467
|
+
threadId: typeof params?.threadId === "string" ? params.threadId : null,
|
|
1468
|
+
// ⚠️ `item.command`(コマンド本文)はここで**捨てる**。台帳へ持つと失効 warn 経由で
|
|
1469
|
+
// 永続ログ(~/.hub/agent.log)に本文が残り、秘密情報が長期保存される。
|
|
1470
|
+
startedAt: now,
|
|
1471
|
+
lastSeenAt: now,
|
|
1472
|
+
})
|
|
1473
|
+
return
|
|
1474
|
+
}
|
|
1475
|
+
if (method === "item/commandExecution/outputDelta") {
|
|
1476
|
+
const itemId = params?.itemId
|
|
1477
|
+
if (typeof itemId !== "string" || !itemId) return
|
|
1478
|
+
const entry = ledger.get(itemId)
|
|
1479
|
+
if (entry) entry.lastSeenAt = now
|
|
1480
|
+
}
|
|
1481
|
+
} catch (err) {
|
|
1482
|
+
this.logger?.warn?.(
|
|
1483
|
+
{ err: err?.message, stream_id: session?.stream_id },
|
|
1484
|
+
"codex observe exec item threw",
|
|
1485
|
+
)
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/** 実行中の背景コマンドを持つか(warm 境界の保護判定用)。**先に失効エントリを掃除してから**
|
|
1490
|
+
* 判定する。失効は「最後に生存を確認してから CODEX_EXEC_PROTECT_TTL_MS」。
|
|
1491
|
+
* ⚠️ 失効させる = そのセッションは kill されうる、という意味なので必ず warn を残す
|
|
1492
|
+
* (黙って kill 可能にしない)。
|
|
1493
|
+
* ⚠️ warn に**コマンド本文を載せない**(この warn は永続ログへ出る。理由は liveExecItems の
|
|
1494
|
+
* コメント参照)。診断は `item_id` と 2 つの thread id で辿る:
|
|
1495
|
+
* - `thread_id` … セッションの root スレッド(`session.threadId`)。**意味を変えないこと**
|
|
1496
|
+
* (既存のログ集計がこのキーで root を前提にしている)
|
|
1497
|
+
* - `exec_thread_id`… その commandExecution の通知に載っていた `params.threadId`(子スレッド
|
|
1498
|
+
* 起因かを切り分けたいとき用。root を上書きせず**別名**で出す) */
|
|
1499
|
+
_hasLiveExec(session, now = Date.now()) {
|
|
1500
|
+
const ledger = session?.liveExecItems
|
|
1501
|
+
if (!ledger || ledger.size === 0) return false
|
|
1502
|
+
for (const [itemId, entry] of ledger) {
|
|
1503
|
+
const elapsed = now - (entry?.lastSeenAt ?? 0)
|
|
1504
|
+
if (elapsed <= CODEX_EXEC_PROTECT_TTL_MS) continue
|
|
1505
|
+
ledger.delete(itemId)
|
|
1506
|
+
this.logger?.warn?.(
|
|
1507
|
+
{
|
|
1508
|
+
stream_id: session?.stream_id,
|
|
1509
|
+
thread_id: session?.threadId ?? null,
|
|
1510
|
+
item_id: itemId,
|
|
1511
|
+
exec_thread_id: entry?.threadId ?? null,
|
|
1512
|
+
elapsed_s: Math.round(elapsed / 1000),
|
|
1513
|
+
ttl_ms: CODEX_EXEC_PROTECT_TTL_MS,
|
|
1514
|
+
},
|
|
1515
|
+
"codex exec protection expired (session is reapable again; a still-running command would be killed)",
|
|
1516
|
+
)
|
|
1517
|
+
}
|
|
1518
|
+
return ledger.size > 0
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1348
1521
|
_onNotification(session, msg) {
|
|
1349
1522
|
const { stream_id } = session
|
|
1350
1523
|
// app-server からの通知 = ターンが進行している証跡。これで touch することで、
|
|
1351
1524
|
// 正常に走っているターンは reaper に回収されず、通知が途絶えたスタックターンだけが
|
|
1352
1525
|
// 放棄セッションとして回収される。
|
|
1353
1526
|
this._touch(session)
|
|
1527
|
+
// 実行中の背景コマンド台帳の更新は **ここ 1 箇所** で行う(switch の中に散らさない)。
|
|
1528
|
+
// 全通知がこの関数を通るので、ここが唯一の合流点になる(経路ごとに配線すると、method を
|
|
1529
|
+
// 足したときに無言で観測漏れする)。claude 側の `_emit` → `_observeTaskEvent` と同じ設計。
|
|
1530
|
+
this._observeExecItem(session, msg)
|
|
1354
1531
|
// 連携カード種別 (Phase 2, cross-review round1 major#2): タグ判定を switch より前に行い、
|
|
1355
1532
|
// **history にもタグ済み item を積む**。旧実装は未タグの msg.params.item を history へ入れてから
|
|
1356
1533
|
// ライブ emit だけタグしていたため、cwd reattach で historyItems が返ると連携カードが通常の青い
|
|
@@ -1900,22 +2077,14 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1900
2077
|
* busy: boolean, detached: boolean, idle_ms: number}>}
|
|
1901
2078
|
*/
|
|
1902
2079
|
describeLiveSessions(cwd) {
|
|
1903
|
-
const now = Date.now()
|
|
1904
2080
|
const out = []
|
|
1905
2081
|
// 案B: sessions は多対一なので distinct session で走査する(購読端末数ぶん重複させない)。
|
|
2082
|
+
// ⚠️ 記述子の組み立ては **_describe に一本化** する(claude 版が describeLiveSessions →
|
|
2083
|
+
// _describeCandidate へ寄せているのと同じ構造)。ここで別に組み立てると、片方だけ直した
|
|
2084
|
+
// ときに「一覧に出るフィールド」と「配送結果・候補一覧に出るフィールド」が無言でずれる。
|
|
1906
2085
|
for (const session of this._liveSessions()) {
|
|
1907
2086
|
if (cwd && session.cwd !== cwd) continue
|
|
1908
|
-
out.push(
|
|
1909
|
-
kind: "codex",
|
|
1910
|
-
cwd: session.cwd,
|
|
1911
|
-
stream_id: session.stream_id,
|
|
1912
|
-
thread_id: session.threadId || null,
|
|
1913
|
-
busy: !!session.activeTurnId,
|
|
1914
|
-
detached: !!session.detachRequested,
|
|
1915
|
-
// idle_ms は「新しさ」の指標(一覧の並び・宛先選定に使う)なので、閲覧 keepalive で
|
|
1916
|
-
// 動く lastActivityMs ではなく **会話ターン境界時刻 lastTurnAtMs** を基準にする。
|
|
1917
|
-
idle_ms: now - (session.lastTurnAtMs ?? session.lastActivityMs ?? now),
|
|
1918
|
-
})
|
|
2087
|
+
out.push(this._describe(session))
|
|
1919
2088
|
}
|
|
1920
2089
|
return out
|
|
1921
2090
|
}
|
|
@@ -1923,7 +2092,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1923
2092
|
/**
|
|
1924
2093
|
* 指定 cwd の「権威セッション」を返す(T06407 増分2, axis2 の単一正本)。同一 cwd に複数
|
|
1925
2094
|
* ライブ codex セッションがあるとき、**最終活性が最新のもの**(=いまユーザーが使っている
|
|
1926
|
-
*
|
|
2095
|
+
* 会話)を選ぶ。⚠️ 表示系(context% の収束)専用の規則であり、**配送(sendToCwd)はこの
|
|
2096
|
+
* 推測を使わない**(曖昧なら配送せず ambiguous を返す)。誤った表示は次 tick で直るが、
|
|
2097
|
+
* 誤配送は取り返しがつかないため、両者の判定を意図的に分けてある。
|
|
1927
2098
|
*
|
|
1928
2099
|
* ⚠️ context%(ドーナツ)は thread 固有の値なので「どのセッションから読んでも同じ」という
|
|
1929
2100
|
* 旧 rate-limit 前提(先着 1 件で十分)は成り立たない。state loop(threadIdForCwd 経由)と
|
|
@@ -1975,19 +2146,15 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1975
2146
|
* 指定 cwd でライブなセッションへメッセージを直接注入する (sibling-inject 用、
|
|
1976
2147
|
* 2026-07-04)。Claude 版 `sendToCwd` の対。
|
|
1977
2148
|
*
|
|
1978
|
-
* ⚠️
|
|
1979
|
-
*
|
|
1980
|
-
*
|
|
1981
|
-
* ゾンビセッションへ注入して「ok なのに画面に出ない」事故が起きた (2026-07-17)。
|
|
1982
|
-
* claude-stream-bridge.mjs の同名メソッドと同じ選択規則に揃えてある。
|
|
2149
|
+
* ⚠️ 宛先解決: 候補を **attached (前面=detach 未要求) 優先** の層に分け、層内が 1 本のときだけ
|
|
2150
|
+
* 配送する。層内が 2 本以上なら推測せず `{status:"ambiguous", candidates}` を返して配送しない
|
|
2151
|
+
* (fail-safe)。claude-stream-bridge.mjs の同名メソッドと同じ判定規則である。
|
|
1983
2152
|
*
|
|
1984
2153
|
* ⚠️ このメソッド自体は認証を行わない (呼び出し元の sibling-inject.mjs が
|
|
1985
2154
|
* src/sibling-token.mjs の capability トークンで入力元を検証する前提)。
|
|
1986
2155
|
*
|
|
1987
2156
|
* ⚠️ 返り値 (2026-07-23, 統一再設計 1-1): claude 版と契約を揃えるため boolean から status
|
|
1988
|
-
* オブジェクトへ変更した。
|
|
1989
|
-
* を見るため claude のような undercount 由来のサイレント誤配送は構造的に起きず、宛先選定は
|
|
1990
|
-
* 従来どおり「会話ターン境界時刻が最新」のヒューリスティックを維持する (挙動不変)。
|
|
2157
|
+
* オブジェクトへ変更した。
|
|
1991
2158
|
*
|
|
1992
2159
|
* @param {string} cwd
|
|
1993
2160
|
* @param {string} text
|
|
@@ -1995,6 +2162,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1995
2162
|
* {status:"delivered", session:object, delivery_id:string, accepted_via:string}
|
|
1996
2163
|
* | {status:"delivery_unconfirmed", session:object, delivery_id:string, accepted_via:string, reason:string}
|
|
1997
2164
|
* | {status:"delivery_failed", session:object, delivery_id:string, reason:string}
|
|
2165
|
+
* | {status:"ambiguous", candidates:object[]}
|
|
1998
2166
|
* | {status:"not_found"}
|
|
1999
2167
|
* >}
|
|
2000
2168
|
*/
|
|
@@ -2005,27 +2173,30 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2005
2173
|
// attached (前面=detach 未要求) 優先 (2026-07-23 統一再設計 1-1, cross-review 指摘4):
|
|
2006
2174
|
// detach 済み warm セッションが lastTurnAtMs 上たまたま新しくても、attached な現行セッションが
|
|
2007
2175
|
// あればそちらへ配送する (browser を離れた/畳まれかけの旧会話 ghost への誤配送を防ぐ)。
|
|
2008
|
-
// claude と違い codex は同一 cwd に複数 attached が併存し得る (別スレッドの新会話 / ターン
|
|
2009
|
-
// 実行中) ため fail-safe ambiguous にはせず、tier 内は会話ターン境界時刻が最新のものを選ぶ
|
|
2010
|
-
// (閲覧 keepalive で動く lastActivityMs は使わない = 見ているだけの古い会話が本命を追い越さない)。
|
|
2011
2176
|
const attached = cands.filter((s) => !s.detachRequested)
|
|
2012
2177
|
const tier = attached.length ? attached : cands
|
|
2013
|
-
|
|
2014
|
-
let bestAt = -1
|
|
2015
|
-
for (const session of tier) {
|
|
2016
|
-
const at = session.lastTurnAtMs ?? session.lastActivityMs ?? 0
|
|
2017
|
-
if (at >= bestAt) {
|
|
2018
|
-
bestAt = at
|
|
2019
|
-
best = session
|
|
2020
|
-
}
|
|
2021
|
-
}
|
|
2022
|
-
if (!best) {
|
|
2178
|
+
if (!tier.length) {
|
|
2023
2179
|
this.logger?.warn?.(
|
|
2024
2180
|
{ cwd, live_sessions: this.describeLiveSessions() },
|
|
2025
2181
|
"codex sendToCwd: no live session for cwd (ask-sibling will fail)",
|
|
2026
2182
|
)
|
|
2027
2183
|
return { status: "not_found" }
|
|
2028
2184
|
}
|
|
2185
|
+
// 層内が 2 本以上なら **推測せず配送しない** (fail-safe。claude 版 sendToCwd と同じ判定):
|
|
2186
|
+
// 旧実装は tier 内で「会話ターン境界時刻 lastTurnAtMs が最新のもの」を選んでいたが、これは
|
|
2187
|
+
// 「同一 cwd の live は実質 1 本 (2 本目は transient)」という前提の上に成り立つ推測だった。
|
|
2188
|
+
// 同一 cwd に複数 live を常態として許す方針へ変わった以上、この推測は誤配送の温床になる
|
|
2189
|
+
// (別スレッドの新会話へ配ってしまう / 見た目は ok なのに本命の画面には出ない)。曖昧なときは
|
|
2190
|
+
// 候補を返して呼び出し側に stream_id / thread_id で名指しさせる (sendToStream)。
|
|
2191
|
+
if (tier.length > 1) {
|
|
2192
|
+
const candidates = tier.map((s) => this._describe(s))
|
|
2193
|
+
this.logger?.warn?.(
|
|
2194
|
+
{ cwd, candidates },
|
|
2195
|
+
"codex sendToCwd: ambiguous target (multiple live sessions for cwd; not delivering)",
|
|
2196
|
+
)
|
|
2197
|
+
return { status: "ambiguous", candidates }
|
|
2198
|
+
}
|
|
2199
|
+
const best = tier[0]
|
|
2029
2200
|
// detach 済み warm セッションでも、ask-sibling が使うなら「新しい持ち主が付いた」とみなして
|
|
2030
2201
|
// detachRequested を解除し touch する(LRU 先頭へ)。this.input が改めて _touch するが、注入前に
|
|
2031
2202
|
// 明示しておく。ターン実行中は healthy busy = protected なので reconcile で kill されない。
|
|
@@ -2033,17 +2204,16 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2033
2204
|
this._touch(best)
|
|
2034
2205
|
return this._deliverInjection(best, text, source, {
|
|
2035
2206
|
cwd,
|
|
2036
|
-
selection: "
|
|
2207
|
+
selection: "sole-candidate",
|
|
2037
2208
|
candidates: this.describeLiveSessions(cwd).length,
|
|
2038
2209
|
})
|
|
2039
2210
|
}
|
|
2040
2211
|
|
|
2041
2212
|
/**
|
|
2042
2213
|
* 指定した id (stream_id もしくは thread_id) のライブセッションへ直接注入する
|
|
2043
|
-
* (sibling-inject の **明示宛先指定** 用、2026-07-19 / T06271)。sendToCwd
|
|
2044
|
-
*
|
|
2045
|
-
*
|
|
2046
|
-
* すれば曖昧さを回避できる。
|
|
2214
|
+
* (sibling-inject の **明示宛先指定** 用、2026-07-19 / T06271)。sendToCwd は同一 cwd に
|
|
2215
|
+
* 複数の生きた codex セッションが同居すると (推測配送せず) ambiguous を返して止まる。
|
|
2216
|
+
* 呼び出し側が describeLiveSessions で得た id を明示すれば、その曖昧さを解消して配送できる。
|
|
2047
2217
|
*
|
|
2048
2218
|
* stream_id は WS 再接続 / in-memory reattach で付け替わる (不安定) 一方、thread_id は
|
|
2049
2219
|
* 会話に固定 (安定) なので、**両方を受け付け** どちらで指定しても解決できるようにする。
|
|
@@ -2257,14 +2427,30 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2257
2427
|
}
|
|
2258
2428
|
}
|
|
2259
2429
|
|
|
2260
|
-
/**
|
|
2430
|
+
/**
|
|
2431
|
+
* セッション記述子の **単一定義** (describeLiveSessions / sendToCwd / sendToStream の
|
|
2432
|
+
* 結果整形が全てここを通る)。claude 版 `_describeCandidate` の対。
|
|
2433
|
+
*
|
|
2434
|
+
* ⚠️ なぜ 1 箇所に集約するか (2026-08-02): 同じ「セッションの記述子」が describeLiveSessions と
|
|
2435
|
+
* _describe の 2 か所で別々に定義されていると、片方だけ直したときに無言でずれる。実際
|
|
2436
|
+
* `detached` / `idle_ms` は一覧側にしか無く、sendToCwd の ambiguous 候補一覧 (= 利用者が
|
|
2437
|
+
* 宛先を選ぶために読むもの) には落ちていなかった。`idle_ms` は「どれが最近動いた会話か」を
|
|
2438
|
+
* 示す唯一の手がかりなので、候補一覧に無いと利用者は宛先を選べない。
|
|
2439
|
+
*
|
|
2440
|
+
* `idle_ms` は「新しさ」の指標(一覧の並び・宛先選定に使う)なので、閲覧 keepalive で動く
|
|
2441
|
+
* lastActivityMs ではなく **会話ターン境界時刻 lastTurnAtMs** を基準にする(見ているだけの
|
|
2442
|
+
* 古い会話が本命を追い越さないように)。
|
|
2443
|
+
*/
|
|
2261
2444
|
_describe(session) {
|
|
2445
|
+
const now = Date.now()
|
|
2262
2446
|
return {
|
|
2263
2447
|
kind: "codex",
|
|
2264
2448
|
cwd: session.cwd,
|
|
2265
2449
|
stream_id: session.stream_id,
|
|
2266
2450
|
thread_id: session.threadId || null,
|
|
2267
2451
|
busy: !!session.activeTurnId,
|
|
2452
|
+
detached: !!session.detachRequested,
|
|
2453
|
+
idle_ms: now - (session.lastTurnAtMs ?? session.lastActivityMs ?? now),
|
|
2268
2454
|
}
|
|
2269
2455
|
}
|
|
2270
2456
|
|
|
@@ -2280,6 +2466,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2280
2466
|
* - healthy running turn: `activeTurnId` が立ち、app-server 通知が STUCK_TTL 以内に届いている。
|
|
2281
2467
|
* activeTurnId 固着で通知が STUCK_TTL 途絶えたもの(stuck/ハング)は protected から外し reap
|
|
2282
2468
|
* 対象にする(プロセスリーク・固着の救済)。
|
|
2469
|
+
* - 実行中の背景コマンド(`liveExecItems` が非空): `turn/completed` の後も走り続ける
|
|
2470
|
+
* commandExecution を守る(実測 /tmp/satoru_bgprobe/raw2.jsonl)。claude 側のタスク保護
|
|
2471
|
+
* (`_hasLiveTask`)と対になる仕組みで、失効時間つきなので終了通知を落としても永久化しない。
|
|
2283
2472
|
* @param {object} session
|
|
2284
2473
|
* @param {number} now
|
|
2285
2474
|
* @returns {boolean}
|
|
@@ -2292,6 +2481,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2292
2481
|
// turn RPC accepted 後、対応 userMessage echo を待っている間も protected。
|
|
2293
2482
|
// ここを外すと別 attach の per-cwd reconcile が ACK 先を配達確認前に reap できる。
|
|
2294
2483
|
if (session.pendingInjectSources?.some((entry) => entry.deliveryPending)) return true
|
|
2484
|
+
// 実行中の背景コマンド(commandExecution)を保護する。**この 1 箇所**で足すことで、reap の
|
|
2485
|
+
// 3 経路(per-cwd / N-cwd LRU / idle-TTL・stuck)と総 live 上限 M のすべてに同時に効く
|
|
2486
|
+
// (経路ごとに書くと、経路が増えたときに片方だけ守られる非対称が生まれる)。
|
|
2487
|
+
// ⚠️ activeTurnId の判定より **前** に置くこと。背景コマンドが残るのは turn/completed の
|
|
2488
|
+
// 後(= activeTurnId が null に戻った後)なので、下の early return を通すと届かない。
|
|
2489
|
+
if (this._hasLiveExec(session, now)) return true
|
|
2295
2490
|
if (!session.activeTurnId) return false
|
|
2296
2491
|
return now - (session.lastActivityMs ?? now) <= this._stuckTurnTtlMs
|
|
2297
2492
|
}
|
|
@@ -2332,24 +2527,46 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2332
2527
|
}
|
|
2333
2528
|
|
|
2334
2529
|
/**
|
|
2335
|
-
*
|
|
2336
|
-
*
|
|
2337
|
-
*
|
|
2338
|
-
*
|
|
2339
|
-
*
|
|
2530
|
+
* 総 live 上限 M(warm-bounds-total.mjs)用の **CLI 非依存アダプタ**。live セッションを
|
|
2531
|
+
* `{ kind, cwd, recency, seq, protected, reap }` の共通形へ包んで返すだけ(新しい判断は持たない)。
|
|
2532
|
+
* 多端末購読は _liveSessions() で dedup 済み。
|
|
2533
|
+
* @param {number} [now]
|
|
2534
|
+
* @returns {Array<object>}
|
|
2535
|
+
*/
|
|
2536
|
+
listWarmCandidates(now = Date.now()) {
|
|
2537
|
+
return this._liveSessions().map((s) => ({
|
|
2538
|
+
kind: "codex",
|
|
2539
|
+
cwd: s.cwd,
|
|
2540
|
+
recency: s.lastActivityMs ?? 0,
|
|
2541
|
+
seq: s.seq ?? 0,
|
|
2542
|
+
protected: this._isProtected(s, now),
|
|
2543
|
+
reap: (reason) => this._reapSession(s, now, reason),
|
|
2544
|
+
}))
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
/**
|
|
2548
|
+
* 子⑤ (T06544): warm 保持を「各 cwd K 本(既定 3)× 同時 warm 保持する cwd 数 N(既定 20)」で
|
|
2549
|
+
* 有界化する。旧 `_evictWarmPoolOverflow`(マシン全体の総セッション数 LRU=5)を置換し、境界を
|
|
2550
|
+
* 「総数」ではなく「cwd 単位」で定義する。
|
|
2551
|
+
* Phase 1 (per-cwd=K): 同一 cwd に K 本を超える live session があれば、最終活性が新しい順に
|
|
2552
|
+
* 先頭 K 本を残し、それ以降の **非 protected** を reap する(K=1 なら旧挙動。総量は Phase 2 と
|
|
2553
|
+
* 総 live 上限 M(warm-bounds-total.mjs)で押さえる)。
|
|
2340
2554
|
* Phase 2 (N-cwd LRU): live session を持つ distinct cwd 数が warmCwdMax を超えたら、cwd の最終活性
|
|
2341
2555
|
* が最も古い cwd から、その cwd の非 protected session を reap して cwd 数を上限まで戻す。
|
|
2342
2556
|
* protected(healthy running turn / stuck 未満)は Phase 1/2 いずれでも reap しない(busy 不可侵)。
|
|
2343
2557
|
* 全 cwd が busy 等で上限に収められないときは一時的に超過を許容する(走行中ターンを絶対に殺さない)。
|
|
2344
2558
|
* 多端末購読は 1 session に集約されるため(_liveSessions で dedup)、purely な多端末利用では
|
|
2345
|
-
* per-cwd=
|
|
2559
|
+
* per-cwd=K は自動的に満たされ、Phase 1 は「同一 cwd に別 app-server が K 本を超えてできたとき」
|
|
2560
|
+
* だけ発火する(旧 per-cwd=1 では「2 本以上できたとき」だった)。
|
|
2346
2561
|
* genuine attach 直後と reconcile の両方から呼ぶ。
|
|
2347
2562
|
* @param {number} now
|
|
2348
2563
|
* @returns {number} reap した数
|
|
2349
2564
|
*/
|
|
2350
2565
|
_reconcileWarmBounds(now, reason = "warm-bounds") {
|
|
2351
2566
|
let evicted = 0
|
|
2352
|
-
// Phase 1: per-cwd=
|
|
2567
|
+
// Phase 1: per-cwd=K。cwd ごとに最終活性が新しい順へ並べ、先頭 K 本以外の非 protected を reap。
|
|
2568
|
+
// K はコンストラクタで正の整数へ検証済み(不正値は既定へ戻して warn 済み)。
|
|
2569
|
+
const keepPerCwd = this._warmPerCwdMax
|
|
2353
2570
|
const byCwd = new Map()
|
|
2354
2571
|
for (const s of this._liveSessions()) {
|
|
2355
2572
|
const list = byCwd.get(s.cwd)
|
|
@@ -2357,13 +2574,13 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2357
2574
|
else byCwd.set(s.cwd, [s])
|
|
2358
2575
|
}
|
|
2359
2576
|
for (const list of byCwd.values()) {
|
|
2360
|
-
if (list.length <=
|
|
2577
|
+
if (list.length <= keepPerCwd) continue
|
|
2361
2578
|
// 新しい順。recency 同値は seq(生成順)で必ず新しい方を先頭(= 残す)にする(round5 Finding 1)。
|
|
2362
2579
|
list.sort(
|
|
2363
2580
|
(a, b) =>
|
|
2364
2581
|
(b.lastActivityMs ?? 0) - (a.lastActivityMs ?? 0) || (b.seq ?? 0) - (a.seq ?? 0),
|
|
2365
2582
|
)
|
|
2366
|
-
for (let i =
|
|
2583
|
+
for (let i = keepPerCwd; i < list.length; i += 1) {
|
|
2367
2584
|
if (this._isProtected(list[i], now)) continue // busy は残す(一時的に >1 を許容)
|
|
2368
2585
|
this._reapSession(list[i], now, `${reason}:per-cwd`)
|
|
2369
2586
|
evicted += 1
|
|
@@ -2393,7 +2610,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2393
2610
|
for (const info of evictableCwds) {
|
|
2394
2611
|
if (cwdsEvicted >= overflow) break
|
|
2395
2612
|
for (const s of info.sessions) {
|
|
2396
|
-
// per-cwd
|
|
2613
|
+
// per-cwd 畳み込み後も各 cwd に**最大 K 本**残る(旧 per-cwd=1 の「1 本」ではない)ので、
|
|
2614
|
+
// cwd 内の全 session を回して非 protected だけを reap する。
|
|
2397
2615
|
if (this._isProtected(s, now)) continue
|
|
2398
2616
|
this._reapSession(s, now, `${reason}:cwd-lru`)
|
|
2399
2617
|
evicted += 1
|
|
@@ -2417,8 +2635,11 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2417
2635
|
* 1. **stuck turn**: activeTurnId 固着だが app-server 通知が STUCK_TTL 途絶えた(ハング)→ reap
|
|
2418
2636
|
* (healthy な長ターンは reasoning/output delta で touch されるため該当しない)。
|
|
2419
2637
|
* 2. **long idle**: 非 busy が idle TTL(既定 7 日)無活性 → reap(warm 保持の期限切れ)。
|
|
2420
|
-
* 3. **warm 境界**(子⑤ T06544): `_reconcileWarmBounds` で「各 cwd
|
|
2421
|
-
*
|
|
2638
|
+
* 3. **warm 境界**(子⑤ T06544): `_reconcileWarmBounds` で「各 cwd **K 本(現行の既定 3)**
|
|
2639
|
+
* × 同時 warm cwd 数 N(既定 20)」に有界化。cwd 内で K 本を超えたぶんは最終活性が古い方から
|
|
2640
|
+
* reap し、cwd 数超過は最古 cwd から reap する。
|
|
2641
|
+
* (**旧仕様**は per-cwd=1 =「同一 cwd の重複は最新 1 本へ畳む」だった。1 cwd で複数会話を
|
|
2642
|
+
* 同時に生かすため K へ一般化し、総量は総 live 上限 M(warm-bounds-total.mjs)で押さえる。)
|
|
2422
2643
|
* busy(healthy running turn)は 1〜3 いずれの対象にもならない(走行中ターンを絶対に殺さない)。
|
|
2423
2644
|
*
|
|
2424
2645
|
* 旧 `gcAbandonedSessions`(1h 無活性一律 reap)を置換。detach 済み warm セッションを 1h で
|
|
@@ -2446,7 +2667,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2446
2667
|
reaped += 1
|
|
2447
2668
|
}
|
|
2448
2669
|
}
|
|
2449
|
-
// 3: warm 境界(per-cwd=
|
|
2670
|
+
// 3: warm 境界(per-cwd=K / N-cwd LRU)を適用(stuck/idle reap 後の生存数で判定)。
|
|
2450
2671
|
reaped += this._reconcileWarmBounds(now, "warm-bounds")
|
|
2451
2672
|
return reaped
|
|
2452
2673
|
}
|