@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
package/package.json
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
} from "./tool-result-mask.mjs"
|
|
34
34
|
import { getOrCreateSiblingToken } from "./sibling-token.mjs"
|
|
35
35
|
import { recordInjectSourcePersistent } from "./inject-source-store.mjs"
|
|
36
|
+
import { readPositiveIntEnv, sanitizePositiveInt } from "./positive-int-env.mjs"
|
|
37
|
+
import { nextSessionSeq } from "./session-seq.mjs"
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
40
|
* sibling-inject (2026-07-04) 用: spawn する claude プロセス自身の環境変数に、
|
|
@@ -181,20 +183,87 @@ function readNeedsAuthCacheNames() {
|
|
|
181
183
|
* sessions に stream_id が永久残留し、最後の 1 台が外れないと idle softDetach が起動しない。
|
|
182
184
|
* 正常に開いている端末は input が無くても WS heartbeat 等で活性更新されないため、閾値は
|
|
183
185
|
* 長め (既定 1 時間) にして「明らかに死んだ」端末のみを掃除する。env で調整可能。 */
|
|
184
|
-
const DEAD_TERMINAL_TTL_MS =
|
|
185
|
-
|
|
186
|
+
const DEAD_TERMINAL_TTL_MS = readPositiveIntEnv(
|
|
187
|
+
"HUB_AGENT_DEAD_TERMINAL_TTL_MS",
|
|
188
|
+
60 * 60 * 1000,
|
|
189
|
+
)
|
|
186
190
|
|
|
187
|
-
/** 子⑤ (T06544): warm 保持を「各 cwd
|
|
191
|
+
/** 子⑤ (T06544): warm 保持を「各 cwd K 本 × 同時 warm 保持する cwd 数 N(既定 20)」で有界化する
|
|
188
192
|
* 上限(codex-stream-bridge.mjs の WARM_CWD_MAX と対)。claude は従来 per-session の softDetach
|
|
189
193
|
* タイマー(7 日)だけで件数上限が無かったため、多数の cwd を渡り歩くと warm セッション(= SDK
|
|
190
194
|
* 常駐 query プロセス)が N 個を超えて蓄積し得た。cwd 単位の LRU で最古 cwd から早期 reap する。
|
|
191
195
|
* env HUB_AGENT_WARM_CWD_MAX で調整可能(codex と共有)。 */
|
|
192
|
-
const WARM_CWD_MAX =
|
|
196
|
+
const WARM_CWD_MAX = readPositiveIntEnv("HUB_AGENT_WARM_CWD_MAX", 20)
|
|
197
|
+
|
|
198
|
+
/** 1 cwd あたりの live セッション上限 K(既定 3)。旧仕様の per-cwd=1 を一般化したもの。
|
|
199
|
+
* 「総量は増やさず配分の自由度だけ上げる」ため、総 live 上限 M(warm-bounds-total.mjs)と
|
|
200
|
+
* セットで効かせる。⚠️ 既存 env(HUB_AGENT_WARM_CWD_MAX = 同時 warm 保持する cwd 数 N)の
|
|
201
|
+
* 意味は変えない。新しい上限は新しい env 名で足す(codex 側の WARM_PER_CWD_MAX と対)。 */
|
|
202
|
+
const WARM_PER_CWD_MAX = readPositiveIntEnv("HUB_AGENT_WARM_PER_CWD_MAX", 3)
|
|
203
|
+
|
|
204
|
+
/** タスク実行中セッションを warm 境界の刈り取りから保護する際の**失効時間**(**鼓動が観測できる
|
|
205
|
+
* 種別**用。既定 30 分)。「最後に生存を確認してから」の相対時間で、`task_progress` が届くたびに
|
|
206
|
+
* 先送りされる。実測(/tmp/bgprobe/raw2.jsonl)で `local_agent`(Agent ツールのサブエージェント)
|
|
207
|
+
* は task_progress の鼓動が届くため、この短い TTL で十分に走行中を守れる。
|
|
208
|
+
* ⚠️ これは**仕様上の最大保護時間**であり、これを超えて鼓動が途絶えたタスクは保護されない
|
|
209
|
+
* (終了イベントを取りこぼしたときに保護が永久化しないための上限として必須)。 */
|
|
210
|
+
const TASK_PROTECT_TTL_MS = readPositiveIntEnv(
|
|
211
|
+
"HUB_AGENT_TASK_PROTECT_TTL_MS",
|
|
212
|
+
30 * 60 * 1000,
|
|
213
|
+
)
|
|
193
214
|
|
|
194
|
-
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
|
|
215
|
+
/** **鼓動が来ない種別**(`local_bash` = run_in_background の Bash、および未知の task_type)用の
|
|
216
|
+
* 失効時間(既定 4 時間)。
|
|
217
|
+
*
|
|
218
|
+
* 【なぜ分けるか】実測で `local_bash` は task_progress が **1 件も来ない**(40 秒でも 240 秒でも
|
|
219
|
+
* 0 件。/tmp/bgprobe/raw2.jsonl, raw4.jsonl)。全種別を共通 TTL にすると lastSeenAt が開始時刻から
|
|
220
|
+
* 動かず、実質「開始から 30 分」になり、**30 分を超える正常な背景コマンド(ビルド・同期・
|
|
221
|
+
* サーバー等)が実行中なのに保護を失う**。終了イベント(task_updated / task_notification)は
|
|
222
|
+
* 240 秒後でも確実に届くことを実測済みなので、TTL を延ばしても通常は終了で解ける。
|
|
223
|
+
*
|
|
224
|
+
* 【未知の task_type もこちら】鼓動が来るか分からないものを短い TTL で切ると実行中を刈る。
|
|
225
|
+
* 「未知の status を終端扱いしない」(TASK_TERMINAL_STATUSES)と同じく安全側へ倒す。
|
|
226
|
+
*
|
|
227
|
+
* ⚠️ これも**仕様上の最大保護時間**で、**4 時間を超える背景コマンドは保護されない**。
|
|
228
|
+
*
|
|
229
|
+
* 【有界なのは「時間」だけで、「件数」ではない】(cross-review round2 minor)
|
|
230
|
+
* 1 エントリあたりの保護時間は TTL で有界(4 時間で必ず頭打ち)。一方で **同時に保護されうる
|
|
231
|
+
* セッション数には K(per-cwd)も N(cwd 数)も M(総 live 上限、warm-bounds-total.mjs)も効かない**。
|
|
232
|
+
* すべての境界が protected を skip する設計なので、4 時間以内に別セッションで背景コマンドを
|
|
233
|
+
* 増やせば、保護中の合計は M を超えて増え続ける。これは「走行中を絶対に殺さない」を優先した
|
|
234
|
+
* **明示的な超過受容**であり、**M をハードな資源上限と読んではいけない**。
|
|
235
|
+
* 超過量は warm-bounds-total.mjs が返す `liveTotal` / `unresolved` のログで観測できる
|
|
236
|
+
* (運用判断はその実測値を見る)。
|
|
237
|
+
*
|
|
238
|
+
* ⚠️ 将来ハード上限(保護中でも刈る)を足すなら、**実行中タスクを中断するという新しい失敗モード**
|
|
239
|
+
* が生まれる。この TTL 設計の延長で暗黙に足さないこと。足すなら victim の選び方・利用者への通知・
|
|
240
|
+
* 再開可否を別仕様として設計する。 */
|
|
241
|
+
const BG_COMMAND_PROTECT_TTL_MS = readPositiveIntEnv(
|
|
242
|
+
"HUB_AGENT_BG_COMMAND_PROTECT_TTL_MS",
|
|
243
|
+
4 * 60 * 60 * 1000,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
/** `task_progress` の鼓動が届くと**実測で確認済み**の task_type。ここに無い種別(`local_bash` /
|
|
247
|
+
* 未知)は鼓動が来ない前提で長い TTL(BG_COMMAND_PROTECT_TTL_MS)を適用する。 */
|
|
248
|
+
const HEARTBEAT_TASK_TYPES = new Set(["local_agent"])
|
|
249
|
+
|
|
250
|
+
/** task_type に応じた保護の失効時間を返す。未知の種別は**長い方**(安全側)。 */
|
|
251
|
+
function taskProtectTtlFor(taskType) {
|
|
252
|
+
return HEARTBEAT_TASK_TYPES.has(taskType) ? TASK_PROTECT_TTL_MS : BG_COMMAND_PROTECT_TTL_MS
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** SDK の `system:task_updated` が patch.status に載せる**終端**ステータス。
|
|
256
|
+
* 出典: node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts の `SDKTaskUpdatedMessage`
|
|
257
|
+
* (`status?: 'pending' | 'running' | 'completed' | 'failed' | 'killed' | 'paused'`, sdk.d.ts:4101)。
|
|
258
|
+
* ⚠️ `paused` は終端に含めない(再開しうるため)。ここに載っていない未知の値は「まだ走行中かも
|
|
259
|
+
* しれない」とみなして保護を解除しない(未知の状態を勝手に終端扱いして走行中タスクを刈らない
|
|
260
|
+
* ため。保護の上限は失効時間が保証する)。
|
|
261
|
+
* ⚠️ SDK 更新で union が変わったら test/claude-task-status-drift.test.mjs が落ちる。 */
|
|
262
|
+
export const TASK_TERMINAL_STATUSES = new Set(["completed", "failed", "killed"])
|
|
263
|
+
|
|
264
|
+
/** 上と対になる**非終端**ステータス(同じ union の残り全部)。分類の網羅性をテストで検査するために
|
|
265
|
+
* 明示的に持つ(SDK に未知の値が増えたら「どちらでもない」ものとして検出できる)。 */
|
|
266
|
+
export const TASK_NONTERMINAL_STATUSES = new Set(["pending", "running", "paused"])
|
|
198
267
|
|
|
199
268
|
/**
|
|
200
269
|
* モデル能力レジストリ (2026-07-11): モデル名 → { supportsEffort, supportsXhighEffort }。
|
|
@@ -391,8 +460,10 @@ export class ClaudeStreamSession {
|
|
|
391
460
|
* 使った時刻」が残り、最古 cwd から LRU reap できる。 */
|
|
392
461
|
this._lastActivityMs = Date.now()
|
|
393
462
|
/** 子⑤ round5 Finding 1: 生成順の単調増加シーケンス(warm 境界のタイブレーク用。値が大きい =
|
|
394
|
-
* 新しい)。recency 同値時に per-cwd=
|
|
395
|
-
|
|
463
|
+
* 新しい)。recency 同値時に per-cwd=K が新しいセッションを残せるようにする。
|
|
464
|
+
* ⚠️ カウンタは **claude / codex 共通**(session-seq.mjs)。総 live 上限 M は両 CLI を合算して
|
|
465
|
+
* 古い順に刈るため、CLI ごとに別カウンタだと CLI をまたいだ比較が成立しない。 */
|
|
466
|
+
this._seq = nextSessionSeq()
|
|
396
467
|
this.cwd = cwd
|
|
397
468
|
this.model = model || null
|
|
398
469
|
this.permissionMode = permissionMode || null
|
|
@@ -496,6 +567,20 @@ export class ClaudeStreamSession {
|
|
|
496
567
|
* 本文一致で _emit が消費してタグ付けする。人間の実入力や tool_result は本 set に無いので
|
|
497
568
|
* タグされない (誤タグ防止)。echo が来ない稀ケースの無限成長は上限で古い順に切る。 */
|
|
498
569
|
this._pendingInjectSources = []
|
|
570
|
+
|
|
571
|
+
/** 走行中タスク(Agent ツールのサブエージェント / 背景 Bash コマンド)の台帳。
|
|
572
|
+
* key = SDK の `task_id`、value = { toolUseId, taskType, startedAt, lastSeenAt }。
|
|
573
|
+
*
|
|
574
|
+
* ⚠️ key を `tool_use_id` にしてはいけない: 終了イベントの片方(`system:task_updated`)は
|
|
575
|
+
* `tool_use_id` を持たず `task_id` しか載せないため、tool_use_id を鍵にすると終了を
|
|
576
|
+
* 取りこぼして保護が居座る(実測ログ /tmp/bgprobe/raw2.jsonl で確認)。
|
|
577
|
+
*
|
|
578
|
+
* ⚠️ 前面 / 背景を区別しない(確定した設計判断)。前面実行中はそもそも `_busy` で保護される
|
|
579
|
+
* ので区別しても挙動は変わらず、区別するには tool_use の `run_in_background` と突き合わせる
|
|
580
|
+
* 第 2 の仕組みが要る。一方、背景コマンドの終了イベントは**ターン終了後**に届く(実測:
|
|
581
|
+
* `result` の 14 秒後)ため、背景は保護対象に含める必要がある。 */
|
|
582
|
+
this._liveTasks = new Map()
|
|
583
|
+
|
|
499
584
|
/** ultracode (0.6.28): 常駐 query へ現在適用済みの ultracode 状態。ターン単位の
|
|
500
585
|
* ワンショット適用を applyFlagSettings で reconcile する際の差分判定に使う。
|
|
501
586
|
* query を (再)起動すると flag settings は既定に戻るため、_runResidentQuery 冒頭で
|
|
@@ -620,7 +705,109 @@ export class ClaudeStreamSession {
|
|
|
620
705
|
if (idx !== -1) this._pendingInjectSources.splice(idx, 1)
|
|
621
706
|
}
|
|
622
707
|
|
|
708
|
+
/**
|
|
709
|
+
* SDK の `system:task_*` を観測して走行中タスク台帳(_liveTasks)を更新する。
|
|
710
|
+
*
|
|
711
|
+
* 呼び出しは `_emit` の先頭 1 箇所だけ。常駐ループ・per-message ループ・jsonl watcher の
|
|
712
|
+
* 3 経路が必ず `_emit` を通るため、ここが唯一の合流点になる(経路ごとに配線すると、
|
|
713
|
+
* どれか 1 本を足したときに無言で観測漏れする)。
|
|
714
|
+
*
|
|
715
|
+
* 実測(/tmp/bgprobe/raw2.jsonl, raw3.jsonl)に基づく扱い:
|
|
716
|
+
* - `task_started` : 新規登録(前面 / 背景の両方が出す)
|
|
717
|
+
* - `task_progress` : **既存エントリがあるときだけ** lastSeenAt を延ばす。エントリが
|
|
718
|
+
* 無ければ新規作成しない(終了後に遅れて届いた進捗でよみがえるのを
|
|
719
|
+
* 防ぐ)。⚠️ ここは「安全側」ではない(cross-review round2 nit):
|
|
720
|
+
* 開始イベント(task_started)を本当に取りこぼしていた場合、実行中
|
|
721
|
+
* なのに保護が付かず reap されうる。**過剰保護と終了後の復活を避ける
|
|
722
|
+
* ため、旧挙動(保護しない)へ fail-open している**というトレードオフ。
|
|
723
|
+
* ⚠️ この記述を根拠に「未知の progress から新規登録する」実装へ
|
|
724
|
+
* 変えないこと。それをやると遅延イベントで保護が復活する。
|
|
725
|
+
* - `task_updated` : patch.status が終端なら削除。**未知の値なら削除せず lastSeenAt 更新**
|
|
726
|
+
* - `task_notification` : status によらず削除(冪等。同一 task_id が複数回来ても壊れない)
|
|
727
|
+
*
|
|
728
|
+
* ⚠️ 例外を外へ投げないこと。`_emit` は全メッセージが通る経路なので、ここで throw すると
|
|
729
|
+
* ストリーム全体が壊れる。すべて握って logger?.warn に留める。
|
|
730
|
+
* @param {object} msg SDK メッセージ
|
|
731
|
+
* @param {number} [now] 時刻(テストから注入可能)
|
|
732
|
+
*/
|
|
733
|
+
_observeTaskEvent(msg, now = Date.now()) {
|
|
734
|
+
try {
|
|
735
|
+
if (!msg || msg.type !== "system") return
|
|
736
|
+
const subtype = msg.subtype
|
|
737
|
+
if (
|
|
738
|
+
subtype !== "task_started" &&
|
|
739
|
+
subtype !== "task_progress" &&
|
|
740
|
+
subtype !== "task_updated" &&
|
|
741
|
+
subtype !== "task_notification"
|
|
742
|
+
) {
|
|
743
|
+
return
|
|
744
|
+
}
|
|
745
|
+
const taskId = msg.task_id
|
|
746
|
+
if (typeof taskId !== "string" || !taskId) return
|
|
747
|
+
if (subtype === "task_started") {
|
|
748
|
+
this._liveTasks.set(taskId, {
|
|
749
|
+
toolUseId: typeof msg.tool_use_id === "string" ? msg.tool_use_id : null,
|
|
750
|
+
taskType: typeof msg.task_type === "string" ? msg.task_type : null,
|
|
751
|
+
startedAt: now,
|
|
752
|
+
lastSeenAt: now,
|
|
753
|
+
})
|
|
754
|
+
return
|
|
755
|
+
}
|
|
756
|
+
if (subtype === "task_notification") {
|
|
757
|
+
this._liveTasks.delete(taskId)
|
|
758
|
+
return
|
|
759
|
+
}
|
|
760
|
+
if (subtype === "task_updated") {
|
|
761
|
+
const status = msg.patch?.status
|
|
762
|
+
if (typeof status === "string" && TASK_TERMINAL_STATUSES.has(status)) {
|
|
763
|
+
this._liveTasks.delete(taskId)
|
|
764
|
+
return
|
|
765
|
+
}
|
|
766
|
+
// 未知 / 非終端の status: 走行中とみなして生存を更新する(失効時間が上限を保証する)。
|
|
767
|
+
const entry = this._liveTasks.get(taskId)
|
|
768
|
+
if (entry) entry.lastSeenAt = now
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
// task_progress: 生存の鼓動。既存エントリのみ延命する(新規作成はしない)。
|
|
772
|
+
const entry = this._liveTasks.get(taskId)
|
|
773
|
+
if (entry) entry.lastSeenAt = now
|
|
774
|
+
} catch (err) {
|
|
775
|
+
this.logger?.warn(
|
|
776
|
+
{ err: err?.message, stream_id: this.stream_id },
|
|
777
|
+
"observe task event threw",
|
|
778
|
+
)
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** 走行中タスクを持つか(warm 境界の保護判定用)。失効したエントリを掃除してから判定する。
|
|
783
|
+
* 失効は「最後に生存を確認してから」の相対時間で、**task_type ごとに TTL が違う**
|
|
784
|
+
* (鼓動が来る local_agent は 30 分 / 鼓動が来ない local_bash・未知は 4 時間)。
|
|
785
|
+
* ⚠️ 失効させて保護を落とす = そのセッションは kill されうる、という意味なので、**必ず warn を
|
|
786
|
+
* 残す**(黙って kill 可能にしない。後から「なぜ実行中に畳まれたのか」を追えるようにする)。 */
|
|
787
|
+
_hasLiveTask(now = Date.now()) {
|
|
788
|
+
if (!this._liveTasks || this._liveTasks.size === 0) return false
|
|
789
|
+
for (const [taskId, entry] of this._liveTasks) {
|
|
790
|
+
const ttl = taskProtectTtlFor(entry?.taskType)
|
|
791
|
+
const elapsed = now - (entry?.lastSeenAt ?? 0)
|
|
792
|
+
if (elapsed <= ttl) continue
|
|
793
|
+
this._liveTasks.delete(taskId)
|
|
794
|
+
this.logger?.warn?.(
|
|
795
|
+
{
|
|
796
|
+
stream_id: this.stream_id,
|
|
797
|
+
task_id: taskId,
|
|
798
|
+
task_type: entry?.taskType ?? null,
|
|
799
|
+
elapsed_s: Math.round(elapsed / 1000),
|
|
800
|
+
ttl_ms: ttl,
|
|
801
|
+
},
|
|
802
|
+
"task protection expired (session is reapable again; a still-running task would be killed)",
|
|
803
|
+
)
|
|
804
|
+
}
|
|
805
|
+
return this._liveTasks.size > 0
|
|
806
|
+
}
|
|
807
|
+
|
|
623
808
|
_emit(event) {
|
|
809
|
+
// 走行中タスクの観測は全経路の合流点であるここで 1 回だけ行う(例外は内部で握る)。
|
|
810
|
+
this._observeTaskEvent(event)
|
|
624
811
|
if (!this.onEvent) return
|
|
625
812
|
let out = event
|
|
626
813
|
if (MASK_ENABLED) {
|
|
@@ -3353,14 +3540,26 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3353
3540
|
* セッションも保護する(reap すると未処理入力が失われる)。
|
|
3354
3541
|
* R1(c) 多ウィンドウ相互 reap 抑止: 2 端末以上が現在ライブ購読中の多端末共有セッション
|
|
3355
3542
|
* (T06542) も保護する。同一 cwd で別 session_id を開いた新規 1 本を残すために、複数
|
|
3356
|
-
* ウィンドウが見ている共有セッションを per-cwd
|
|
3357
|
-
* (単端末の重複セッション collapse は従来どおり働く: 単端末は count=1 で非保護)。
|
|
3358
|
-
|
|
3543
|
+
* ウィンドウが見ている共有セッションを per-cwd / cwd-LRU が刈ってしまう事故を防ぐ
|
|
3544
|
+
* (単端末の重複セッション collapse は従来どおり働く: 単端末は count=1 で非保護)。
|
|
3545
|
+
* タスク実行中 (_hasLiveTask) も保護する: 背景サブエージェント / 背景コマンドはターン終了後も
|
|
3546
|
+
* 走り続ける(実測で終了イベントは result の 14 秒後)ため、_busy だけでは守れない。保護は
|
|
3547
|
+
* 失効時間つきで、終了イベントを取りこぼしても永久化しない。
|
|
3548
|
+
* ⚠️ 旧コメントは「codex 側には対応物を置いていない(意図的な非対称)」としていたが、これは
|
|
3549
|
+
* **事実として誤り**だったので訂正する。codex-cli 0.145.0 の実測(/tmp/satoru_bgprobe/raw2.jsonl,
|
|
3550
|
+
* raw4.jsonl)では `item/started(commandExecution, status=inProgress)` → `turn/completed` の順に
|
|
3551
|
+
* 届き、**turn/completed の後もそのコマンドは走り続ける**(sleep 120 で確認)。自然終了でも
|
|
3552
|
+
* `item/completed` は届く(sleep 20 の追試で turn/completed の 17 秒後)。したがって codex 側にも
|
|
3553
|
+
* 対応物(`liveExecItems` 台帳 + `_hasLiveExec`)を置いた(codex-stream-bridge.mjs)。
|
|
3554
|
+
* 観測は **通知の台帳**で行い、権威照会 `thread/backgroundTerminals/list` は使わない
|
|
3555
|
+
* (実験 API の有効化が要るうえ、threadId ごとの照会なので子スレッドの背景コマンドを取りこぼす)。 */
|
|
3556
|
+
_isSessionProtected(session, now = Date.now()) {
|
|
3359
3557
|
if (!session) return false
|
|
3360
3558
|
if (session._busy) return true
|
|
3361
3559
|
if (Array.isArray(session._pendingMessages) && session._pendingMessages.length > 0) {
|
|
3362
3560
|
return true
|
|
3363
3561
|
}
|
|
3562
|
+
if (session._hasLiveTask?.(now)) return true
|
|
3364
3563
|
return this._liveTerminalCount(session) >= 2
|
|
3365
3564
|
}
|
|
3366
3565
|
|
|
@@ -3382,26 +3581,62 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3382
3581
|
}
|
|
3383
3582
|
|
|
3384
3583
|
/**
|
|
3385
|
-
*
|
|
3584
|
+
* 総 live 上限 M(warm-bounds-total.mjs)用の **CLI 非依存アダプタ**。live セッションを
|
|
3585
|
+
* `{ kind, cwd, recency, seq, protected, reap }` の共通形へ包んで返すだけ(新しい判断は持たない)。
|
|
3586
|
+
* warm セッションは this.sessions から外れ _liveBySession に残るため union を dedup して走査する。
|
|
3587
|
+
* @param {number} [now]
|
|
3588
|
+
* @returns {Array<object>}
|
|
3589
|
+
*/
|
|
3590
|
+
listWarmCandidates(now = Date.now()) {
|
|
3591
|
+
const out = []
|
|
3592
|
+
const seen = new Set()
|
|
3593
|
+
for (const s of [...this.sessions.values(), ...this._liveBySession.values()]) {
|
|
3594
|
+
if (!s || s._closed || seen.has(s)) continue
|
|
3595
|
+
seen.add(s)
|
|
3596
|
+
out.push({
|
|
3597
|
+
kind: "claude",
|
|
3598
|
+
cwd: s.cwd,
|
|
3599
|
+
recency: this._sessionRecency(s),
|
|
3600
|
+
seq: s._seq ?? 0,
|
|
3601
|
+
protected: this._isSessionProtected(s, now),
|
|
3602
|
+
reap: (reason) => this._reapForWarmBounds(s, reason),
|
|
3603
|
+
})
|
|
3604
|
+
}
|
|
3605
|
+
return out
|
|
3606
|
+
}
|
|
3607
|
+
|
|
3608
|
+
/**
|
|
3609
|
+
* 子⑤ (T06544): warm 保持を「各 cwd K 本 × 同時 warm 保持する cwd 数 N(既定 20)」で有界化する
|
|
3386
3610
|
* (codex-stream-bridge.mjs の `_reconcileWarmBounds` と対)。state loop から周期実行する。
|
|
3387
|
-
* Phase 1 (per-cwd=
|
|
3388
|
-
*
|
|
3389
|
-
*
|
|
3611
|
+
* Phase 1 (per-cwd=K): 同一 cwd に K 本を超える live session があれば最終活性が新しい順に
|
|
3612
|
+
* 先頭 K 本を残し、それ以降の **非 busy** を reap する(K の既定は 3。旧仕様の per-cwd=1 を
|
|
3613
|
+
* 一般化したもので、1 cwd で複数会話を同時に生かすため。総量は Phase 2 と総 live 上限 M
|
|
3614
|
+
* (warm-bounds-total.mjs)で押さえる)。
|
|
3390
3615
|
* Phase 2 (N-cwd LRU): live session を持つ distinct cwd 数が上限を超えたら、cwd の最終活性が
|
|
3391
3616
|
* 最も古い cwd から、その cwd の非 busy session を reap して cwd 数を上限まで戻す。
|
|
3392
3617
|
* busy(走行中ターン)は Phase 1/2 いずれでも reap しない(完走を待つ)。warm セッションは
|
|
3393
3618
|
* this.sessions から外れ _liveBySession に残るため、両索引の union を dedup して走査する。
|
|
3394
|
-
* @param {{ maxCwds?: number, now?: number }} [opts] テストから上限/時刻を注入可能。
|
|
3619
|
+
* @param {{ maxCwds?: number, maxPerCwd?: number, now?: number }} [opts] テストから上限/時刻を注入可能。
|
|
3395
3620
|
* @returns {number} reap 数
|
|
3396
3621
|
*/
|
|
3397
|
-
reconcileWarmBounds({ maxCwds
|
|
3398
|
-
|
|
3622
|
+
reconcileWarmBounds({ maxCwds, maxPerCwd, now = Date.now() } = {}) {
|
|
3623
|
+
// ⚠️ 上限は **正の整数**でなければならない。1.5 のような値が for の開始添字に入ると、
|
|
3624
|
+
// undefined の reap が no-op なのに reaped だけ加算され「畳んでいないのに畳んだと報告する」。
|
|
3625
|
+
// 不正値は既定へ戻して warn(黙って floor しない。positive-int-env.mjs 参照)。
|
|
3626
|
+
const cwdLimit = sanitizePositiveInt(maxCwds, WARM_CWD_MAX, {
|
|
3627
|
+
logger: this.logger,
|
|
3628
|
+
label: "claude.warmBounds.maxCwds",
|
|
3629
|
+
})
|
|
3630
|
+
const keepPerCwd = sanitizePositiveInt(maxPerCwd, WARM_PER_CWD_MAX, {
|
|
3631
|
+
logger: this.logger,
|
|
3632
|
+
label: "claude.warmBounds.maxPerCwd",
|
|
3633
|
+
})
|
|
3399
3634
|
const liveSessions = () =>
|
|
3400
3635
|
Array.from(new Set([...this.sessions.values(), ...this._liveBySession.values()])).filter(
|
|
3401
3636
|
(s) => s && !s._closed,
|
|
3402
3637
|
)
|
|
3403
3638
|
let reaped = 0
|
|
3404
|
-
// Phase 1: per-cwd=
|
|
3639
|
+
// Phase 1: per-cwd=K。cwd ごとに最終活性が新しい順へ並べ、先頭 K 本以外の非 busy を reap。
|
|
3405
3640
|
const byCwd = new Map()
|
|
3406
3641
|
for (const s of liveSessions()) {
|
|
3407
3642
|
const list = byCwd.get(s.cwd)
|
|
@@ -3409,14 +3644,14 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3409
3644
|
else byCwd.set(s.cwd, [s])
|
|
3410
3645
|
}
|
|
3411
3646
|
for (const list of byCwd.values()) {
|
|
3412
|
-
if (list.length <=
|
|
3647
|
+
if (list.length <= keepPerCwd) continue
|
|
3413
3648
|
// 新しい順。recency 同値は _seq(生成順)で必ず新しい方を先頭(= 残す)にする(round5 Finding 1)。
|
|
3414
3649
|
list.sort(
|
|
3415
3650
|
(a, b) =>
|
|
3416
3651
|
this._sessionRecency(b) - this._sessionRecency(a) || (b._seq ?? 0) - (a._seq ?? 0),
|
|
3417
3652
|
)
|
|
3418
|
-
for (let i =
|
|
3419
|
-
if (this._isSessionProtected(list[i])) continue // 走行中/pending
|
|
3653
|
+
for (let i = keepPerCwd; i < list.length; i += 1) {
|
|
3654
|
+
if (this._isSessionProtected(list[i], now)) continue // 走行中/pending/タスク実行中は不可侵(一時的に >K を許容)
|
|
3420
3655
|
this._reapForWarmBounds(list[i], "warm-bounds:per-cwd")
|
|
3421
3656
|
reaped += 1
|
|
3422
3657
|
}
|
|
@@ -3432,10 +3667,10 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3432
3667
|
const r = this._sessionRecency(s)
|
|
3433
3668
|
if (r > info.recency) info.recency = r
|
|
3434
3669
|
if ((s._seq ?? 0) > info.seq) info.seq = s._seq ?? 0 // round5 Finding 1: cwd タイブレーク用
|
|
3435
|
-
if (this._isSessionProtected(s)) info.hasProtected = true
|
|
3670
|
+
if (this._isSessionProtected(s, now)) info.hasProtected = true
|
|
3436
3671
|
info.sessions.push(s)
|
|
3437
3672
|
}
|
|
3438
|
-
const overflow = cwdInfo.size -
|
|
3673
|
+
const overflow = cwdInfo.size - cwdLimit
|
|
3439
3674
|
if (overflow > 0) {
|
|
3440
3675
|
// protected(走行中/pending)を含む cwd は退避対象外(上限にカウントはするが reap しない)。
|
|
3441
3676
|
// 最古 recency の cwd から。recency 同値は seq(新しさ)で必ず新しい cwd を残す。
|
|
@@ -3446,7 +3681,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3446
3681
|
for (const info of evictableCwds) {
|
|
3447
3682
|
if (cwdsEvicted >= overflow) break
|
|
3448
3683
|
for (const s of info.sessions) {
|
|
3449
|
-
if (this._isSessionProtected(s)) continue
|
|
3684
|
+
if (this._isSessionProtected(s, now)) continue
|
|
3450
3685
|
this._reapForWarmBounds(s, "warm-bounds:cwd-lru")
|
|
3451
3686
|
reaped += 1
|
|
3452
3687
|
}
|