@cocorograph/hub-agent 0.8.23 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.23",
3
+ "version": "0.8.24",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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
- Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) || 60 * 60 * 1000
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 1 本 × 同時 warm 保持する cwd 数 N(既定 20)」で有界化する
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 = Number(process.env.HUB_AGENT_WARM_CWD_MAX) || 20
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
- /** 子⑤ (T06544, codex-review round5 Finding 1): warm 境界の「最新 1 本を残す」判定で recency が
195
- * 同一ミリ秒になったときのタイブレーク用の単調増加シーケンス(codex 側 CODEX_SESSION_SEQ と対)。
196
- * 生成順が後 = 値が大きい = 新しいので、per-cwd=1 のタイでは必ず新しいセッションを残す。 */
197
- let CLAUDE_SESSION_SEQ = 0
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=1 が新しいセッションを残せるようにする。 */
395
- this._seq = CLAUDE_SESSION_SEQ += 1
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=1 / cwd-LRU が刈ってしまう事故を防ぐ
3357
- * (単端末の重複セッション collapse は従来どおり働く: 単端末は count=1 で非保護)。 */
3358
- _isSessionProtected(session) {
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
- * 子⑤ (T06544): warm 保持を「各 cwd 1 本 × 同時 warm 保持する cwd 数 N(既定 20)」で有界化する
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=1): 同一 cwd に 2 本以上の live session があれば最終活性が最新の 1 本を残し、
3388
- * 残りの **非 busy** を reap する(WS 再接続は購読者追加=同一 session なので通常は発火せず、
3389
- * 別会話を同一 cwd で開始した等で 2 本目の SDK query ができたときだけ畳む)。
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 = WARM_CWD_MAX, now = Date.now() } = {}) {
3398
- void now // recency 比較は相対値のため now は将来の拡張用(署名の対称性維持)。
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=1。cwd ごとに最終活性が新しい順へ並べ、先頭(最新)以外の非 busy を reap。
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 <= 1) continue
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 = 1; i < list.length; i += 1) {
3419
- if (this._isSessionProtected(list[i])) continue // 走行中/pending は不可侵(一時的に >1 を許容)
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 - maxCwds
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
  }
@@ -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
- Number(process.env.HUB_AGENT_CODEX_IDLE_DETACH_TTL_MS) || 7 * 24 * 60 * 60 * 1000
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 1 本 × 同時 warm
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
- Number(process.env.HUB_AGENT_WARM_CWD_MAX) ||
106
- Number(process.env.HUB_AGENT_CODEX_WARM_POOL_MAX) ||
107
- 20
108
-
109
- // 子⑤ (T06544, codex-review round5 Finding 1): warm 境界の「最新 1 本を残す」判定で recency
110
- // (lastActivityMs) が同一ミリ秒になったときのタイブレーク用の単調増加シーケンス。生成順が後 =
111
- // 値が大きい = 新しいので、per-cwd=1 のタイでは **必ず新しく attach したセッションを残す**
112
- // (旧実装は安定ソートの挿入順で旧セッションが残り、新規 attach が即 reap される非決定性があった)。
113
- let CODEX_SESSION_SEQ = 0
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
- Number(process.env.HUB_AGENT_CODEX_STUCK_TURN_TTL_MS) ||
121
- Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) ||
122
- 60 * 60 * 1000
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
- Number(process.env.HUB_AGENT_CODEX_DEAD_TERMINAL_TTL_MS) ||
130
- Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) ||
131
- 60 * 60 * 1000
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
- Number(process.env.HUB_AGENT_CODEX_TURN_START_TIMEOUT_MS) || 30 * 1000
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
- Number(process.env.HUB_AGENT_CODEX_DELIVERY_ECHO_TIMEOUT_MS) || 8 * 1000
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 = Number(process.env.HUB_AGENT_CODEX_HISTORY_MAX_ITEMS) || 200
162
- const HISTORY_MAX_BYTES =
163
- Number(process.env.HUB_AGENT_CODEX_HISTORY_MAX_BYTES) || 1.5 * 1024 * 1024
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 数の上限(既定 20)。各 cwd は 1 本に畳む。
341
- // テストから小さい値を注入可能。
342
- this._warmCwdMax = warmCwdMax ?? WARM_CWD_MAX
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=1 が新しいセッションを残せるようにする。
546
- seq: (CODEX_SESSION_SEQ += 1),
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=1 / N-cwd LRU)を即時
756
- // 適用する(次の reconnect まで ~5s 待たず、同一 cwd の重複や cwd 数超過が居座るのを防ぐ)。
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 が返ると連携カードが通常の青い
@@ -2289,6 +2466,9 @@ export class CodexStreamBridge extends EventEmitter {
2289
2466
  * - healthy running turn: `activeTurnId` が立ち、app-server 通知が STUCK_TTL 以内に届いている。
2290
2467
  * activeTurnId 固着で通知が STUCK_TTL 途絶えたもの(stuck/ハング)は protected から外し reap
2291
2468
  * 対象にする(プロセスリーク・固着の救済)。
2469
+ * - 実行中の背景コマンド(`liveExecItems` が非空): `turn/completed` の後も走り続ける
2470
+ * commandExecution を守る(実測 /tmp/satoru_bgprobe/raw2.jsonl)。claude 側のタスク保護
2471
+ * (`_hasLiveTask`)と対になる仕組みで、失効時間つきなので終了通知を落としても永久化しない。
2292
2472
  * @param {object} session
2293
2473
  * @param {number} now
2294
2474
  * @returns {boolean}
@@ -2301,6 +2481,12 @@ export class CodexStreamBridge extends EventEmitter {
2301
2481
  // turn RPC accepted 後、対応 userMessage echo を待っている間も protected。
2302
2482
  // ここを外すと別 attach の per-cwd reconcile が ACK 先を配達確認前に reap できる。
2303
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
2304
2490
  if (!session.activeTurnId) return false
2305
2491
  return now - (session.lastActivityMs ?? now) <= this._stuckTurnTtlMs
2306
2492
  }
@@ -2341,24 +2527,46 @@ export class CodexStreamBridge extends EventEmitter {
2341
2527
  }
2342
2528
 
2343
2529
  /**
2344
- * 子⑤ (T06544): warm 保持を「各 cwd 1 本 × 同時 warm 保持する cwd 数 N(既定 20)」で有界化する。
2345
- * 旧 `_evictWarmPoolOverflow`(マシン全体の総セッション数 LRU=5)を置換し、境界を「総数」ではなく
2346
- * 「cwd 単位」で定義する(親 T06539「1 cwd 1 本化」に整合)。
2347
- * Phase 1 (per-cwd=1): 同一 cwd に 2 本以上の live session があれば、最終活性が最新の 1 本を残し
2348
- * 残りの **非 protected** を reap する(WS 再接続や別スレッド開始で一時的に増えた重複を畳む)。
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)で押さえる)。
2349
2554
  * Phase 2 (N-cwd LRU): live session を持つ distinct cwd 数が warmCwdMax を超えたら、cwd の最終活性
2350
2555
  * が最も古い cwd から、その cwd の非 protected session を reap して cwd 数を上限まで戻す。
2351
2556
  * protected(healthy running turn / stuck 未満)は Phase 1/2 いずれでも reap しない(busy 不可侵)。
2352
2557
  * 全 cwd が busy 等で上限に収められないときは一時的に超過を許容する(走行中ターンを絶対に殺さない)。
2353
2558
  * 多端末購読は 1 session に集約されるため(_liveSessions で dedup)、purely な多端末利用では
2354
- * per-cwd=1 は自動的に満たされ、Phase 1 は「同一 cwd に別 app-server が 2 本以上できたとき」だけ発火する。
2559
+ * per-cwd=K は自動的に満たされ、Phase 1 は「同一 cwd に別 app-server が K 本を超えてできたとき」
2560
+ * だけ発火する(旧 per-cwd=1 では「2 本以上できたとき」だった)。
2355
2561
  * genuine attach 直後と reconcile の両方から呼ぶ。
2356
2562
  * @param {number} now
2357
2563
  * @returns {number} reap した数
2358
2564
  */
2359
2565
  _reconcileWarmBounds(now, reason = "warm-bounds") {
2360
2566
  let evicted = 0
2361
- // Phase 1: per-cwd=1。cwd ごとに最終活性が新しい順へ並べ、先頭(最新)以外の非 protected を reap。
2567
+ // Phase 1: per-cwd=K。cwd ごとに最終活性が新しい順へ並べ、先頭 K 本以外の非 protected を reap。
2568
+ // K はコンストラクタで正の整数へ検証済み(不正値は既定へ戻して warn 済み)。
2569
+ const keepPerCwd = this._warmPerCwdMax
2362
2570
  const byCwd = new Map()
2363
2571
  for (const s of this._liveSessions()) {
2364
2572
  const list = byCwd.get(s.cwd)
@@ -2366,13 +2574,13 @@ export class CodexStreamBridge extends EventEmitter {
2366
2574
  else byCwd.set(s.cwd, [s])
2367
2575
  }
2368
2576
  for (const list of byCwd.values()) {
2369
- if (list.length <= 1) continue
2577
+ if (list.length <= keepPerCwd) continue
2370
2578
  // 新しい順。recency 同値は seq(生成順)で必ず新しい方を先頭(= 残す)にする(round5 Finding 1)。
2371
2579
  list.sort(
2372
2580
  (a, b) =>
2373
2581
  (b.lastActivityMs ?? 0) - (a.lastActivityMs ?? 0) || (b.seq ?? 0) - (a.seq ?? 0),
2374
2582
  )
2375
- for (let i = 1; i < list.length; i += 1) {
2583
+ for (let i = keepPerCwd; i < list.length; i += 1) {
2376
2584
  if (this._isProtected(list[i], now)) continue // busy は残す(一時的に >1 を許容)
2377
2585
  this._reapSession(list[i], now, `${reason}:per-cwd`)
2378
2586
  evicted += 1
@@ -2402,7 +2610,8 @@ export class CodexStreamBridge extends EventEmitter {
2402
2610
  for (const info of evictableCwds) {
2403
2611
  if (cwdsEvicted >= overflow) break
2404
2612
  for (const s of info.sessions) {
2405
- // per-cwd 畳み込み後は各 cwd 1 本のはずだが、念のため非 protected のみ reap。
2613
+ // per-cwd 畳み込み後も各 cwd に**最大 K 本**残る(旧 per-cwd=1 の「1 本」ではない)ので、
2614
+ // cwd 内の全 session を回して非 protected だけを reap する。
2406
2615
  if (this._isProtected(s, now)) continue
2407
2616
  this._reapSession(s, now, `${reason}:cwd-lru`)
2408
2617
  evicted += 1
@@ -2426,8 +2635,11 @@ export class CodexStreamBridge extends EventEmitter {
2426
2635
  * 1. **stuck turn**: activeTurnId 固着だが app-server 通知が STUCK_TTL 途絶えた(ハング)→ reap
2427
2636
  * (healthy な長ターンは reasoning/output delta で touch されるため該当しない)。
2428
2637
  * 2. **long idle**: 非 busy が idle TTL(既定 7 日)無活性 → reap(warm 保持の期限切れ)。
2429
- * 3. **warm 境界**(子⑤ T06544): `_reconcileWarmBounds` で「各 cwd 1 本 × 同時 warm cwd 数 N
2430
- * (既定 20)」に有界化。同一 cwd の重複は最新 1 本へ畳み、cwd 数超過は最古 cwd から reap。
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)で押さえる。)
2431
2643
  * busy(healthy running turn)は 1〜3 いずれの対象にもならない(走行中ターンを絶対に殺さない)。
2432
2644
  *
2433
2645
  * 旧 `gcAbandonedSessions`(1h 無活性一律 reap)を置換。detach 済み warm セッションを 1h で
@@ -2455,7 +2667,7 @@ export class CodexStreamBridge extends EventEmitter {
2455
2667
  reaped += 1
2456
2668
  }
2457
2669
  }
2458
- // 3: warm 境界(per-cwd=1 / N-cwd LRU)を適用(stuck/idle reap 後の生存数で判定)。
2670
+ // 3: warm 境界(per-cwd=K / N-cwd LRU)を適用(stuck/idle reap 後の生存数で判定)。
2459
2671
  reaped += this._reconcileWarmBounds(now, "warm-bounds")
2460
2672
  return reaped
2461
2673
  }
package/src/main.mjs CHANGED
@@ -52,6 +52,7 @@ import { PtyBridge } from "./pty-bridge.mjs"
52
52
  import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
53
53
  import { CodexMcpControl } from "./codex-mcp-control.mjs"
54
54
  import { CodexStreamBridge, classifyCodexInputError } from "./codex-stream-bridge.mjs"
55
+ import { enforceTotalLiveBound } from "./warm-bounds-total.mjs"
55
56
  import { UploadManager } from "./claude-upload.mjs"
56
57
  import { requestSelfUninstall } from "./service-install.mjs"
57
58
  import {
@@ -2239,6 +2240,11 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2239
2240
  // 権威的 stall 判定 (arm 非依存・cap 非依存)。state loop が観測する signal だけで turn の開閉と
2240
2241
  // 無進捗を判定する。旧 lastProgressAtByName + armed ゲートの「フック依存 + cap 140s で消える」死角を解消。
2241
2242
  const stallTracker = new StallTracker({ stallWarnMs: STALL_WARN_MS })
2243
+ // 総 live 上限 M のログ抑制状態。⚠️ **この state loop インスタンスが所有する**(module-global に
2244
+ // 置くと、複数インスタンスや並行テストが互いのログを抑制してしまう)。「超過しているが全部
2245
+ // 保護されていて刈れない」tick は 5 秒ごとに同じ行を出し続けるので、状態が変わったときと
2246
+ // 一定間隔でだけ記録する(warm-bounds-total.mjs の _shouldLog)。
2247
+ const warmBoundsLogState = { lastUnresolved: 0, lastLoggedAtMs: 0 }
2242
2248
  let stopped = false
2243
2249
 
2244
2250
  // RC-8: WS 再接続のたびに差分送信の基準 (lastByName) をクリアし、次 tick で全 session.state を
@@ -2263,8 +2269,10 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2263
2269
  } catch (err) {
2264
2270
  logger?.warn({ err: err?.message }, "gcDeadTerminals failed")
2265
2271
  }
2266
- // 子⑤ (T06544): claude 側 warm 境界(per-cwd=1 / N-cwd LRU)。従来 claude は per-session の
2267
- // softDetach タイマー(7 日)だけで件数上限が無かったため、多数の cwd を渡り歩くと warm な
2272
+ // 子⑤ (T06544): claude 側 warm 境界(per-cwd=K / N-cwd LRU)。K は現行の既定 3
2273
+ // (**旧仕様**は per-cwd=1 で「各 cwd 1 本」だった。1 cwd で複数会話を同時に生かすため
2274
+ // 一般化し、総量は下の総 live 上限 M で押さえる)。従来 claude は per-session の softDetach
2275
+ // タイマー(7 日)だけで件数上限が無かったため、多数の cwd を渡り歩くと warm な
2268
2276
  // SDK 常駐 query が蓄積し得た。codex の reconcileSessions と対で cwd 単位に有界化する。
2269
2277
  try {
2270
2278
  claudeBridge?.reconcileWarmBounds?.()
@@ -2279,6 +2287,18 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2279
2287
  } catch (err) {
2280
2288
  logger?.warn({ err: err?.message }, "gcAbandonedSessions failed")
2281
2289
  }
2290
+ // 総 live 上限 M(claude + codex 合算、既定 20)。per-cwd を 1 → K(既定 3)へ緩めたぶん、
2291
+ // cwd 単位の境界だけでは総量が膨らむため、cwd をまたいだ天井をここで適用する。
2292
+ // ⚠️ 両 CLI の reconcile が **終わった後**に呼ぶ(cwd 単位で畳んだ結果の生存数で判定する)。
2293
+ try {
2294
+ enforceTotalLiveBound({
2295
+ bridges: [claudeBridge, codexBridge].filter(Boolean),
2296
+ logger,
2297
+ logState: warmBoundsLogState,
2298
+ })
2299
+ } catch (err) {
2300
+ logger?.warn({ err: err?.message }, "enforceTotalLiveBound failed")
2301
+ }
2282
2302
  // 実コンテキスト窓サイズ (1M ベータ等) を反映。contextPctFromUsage の分母を
2283
2303
  // 5s ごとに最新化し、ドーナツが 200k 固定で振り切れるのを防ぐ。
2284
2304
  await refreshContextWindow()
@@ -0,0 +1,81 @@
1
+ /**
2
+ * 上限 / TTL 系の env を **1 箇所で検証**する共通ヘルパー(cross-review minor 6 対応)。
3
+ *
4
+ * 【なぜ必要か】旧実装は `Number(process.env.X) || <既定>` で読み、`Math.max(1, v)` しか
5
+ * 掛けていなかった。`Number("1.5")` は 1.5 を通し `Math.max(1, 1.5) === 1.5` なので、
6
+ * その値が **for ループの開始添字**(`for (let i = keepPerCwd; ...)`)に入ると実害が出る:
7
+ * - codex: `list[1.5]` が undefined のまま `_isProtected(undefined, now)` へ渡り
8
+ * `session.pumping` の参照で TypeError
9
+ * - claude: undefined の reap が no-op なのに `reaped` だけ加算され、
10
+ * **実際には畳んでいないのに畳んだと報告する**
11
+ * `Infinity` は `Number("Infinity")` が通るため実質「上限なし」になる。
12
+ *
13
+ * 【なぜ Math.floor で丸めないか】設定ミスを黙って別の値に読み替えると、運用者は
14
+ * 「設定したつもりの値」と「効いている値」がズレたまま気づけない。**既定値へ戻して warn**
15
+ * に統一し、必ず痕跡が残るようにする。
16
+ *
17
+ * 【なぜ「正の安全整数」か】上限(K / M / N)は「何本残すか」の個数、TTL は「何ミリ秒」で、
18
+ * いずれも 1 未満・非整数・非有限に意味が無い。`Number.isSafeInteger` は NaN / Infinity /
19
+ * 小数 をまとめて弾ける唯一の述語なので、これ 1 本で判定する。
20
+ */
21
+
22
+ /** 正の安全整数(>= 1)か。NaN / Infinity / 小数 / 0 / 負数 はすべて false。 */
23
+ export function isPositiveInt(value) {
24
+ return Number.isSafeInteger(value) && value >= 1
25
+ }
26
+
27
+ /** warn の出力先。logger が無い(モジュール読み込み時など)場合は stderr へ落とす。 */
28
+ function warn(logger, fields, message) {
29
+ if (typeof logger?.warn === "function") {
30
+ logger.warn(fields, message)
31
+ return
32
+ }
33
+ // eslint-disable-next-line no-console
34
+ console.warn(`[hub-agent] ${message}`, fields)
35
+ }
36
+
37
+ /**
38
+ * env から正の整数を読む。**不正値は既定値へ戻して warn**(黙って丸めない)。
39
+ *
40
+ * @param {string|string[]} names 環境変数名。配列なら「先に設定されている方を優先」して順に評価し、
41
+ * 不正値だった名前は warn したうえで次の候補へ落ちる(後方互換 env を持つ設定のため)。
42
+ * @param {number} fallback 既定値(正の安全整数であること)
43
+ * @param {object} [logger]
44
+ * @returns {number}
45
+ */
46
+ export function readPositiveIntEnv(names, fallback, logger) {
47
+ const list = Array.isArray(names) ? names : [names]
48
+ for (const name of list) {
49
+ const raw = process.env[name]
50
+ if (raw === undefined || raw === null || String(raw).trim() === "") continue
51
+ const parsed = Number(raw)
52
+ if (isPositiveInt(parsed)) return parsed
53
+ warn(
54
+ logger,
55
+ { env: name, value: String(raw), fallback },
56
+ "invalid env value (expected a positive integer); falling back to the default",
57
+ )
58
+ }
59
+ return fallback
60
+ }
61
+
62
+ /**
63
+ * 実行時に注入された上限値(テスト / 呼び出し側 opts)を検証する。env と同じ規則で、
64
+ * 不正なら **既定値へ戻して warn** する(env だけ守っても opts 経由で同じ穴が開くため)。
65
+ *
66
+ * @param {*} value
67
+ * @param {number} fallback
68
+ * @param {{ logger?: object, label?: string }} [opts]
69
+ * @returns {number}
70
+ */
71
+ export function sanitizePositiveInt(value, fallback, { logger, label = "bound" } = {}) {
72
+ if (value === undefined || value === null) return fallback
73
+ const parsed = typeof value === "number" ? value : Number(value)
74
+ if (isPositiveInt(parsed)) return parsed
75
+ warn(
76
+ logger,
77
+ { setting: label, value: String(value), fallback },
78
+ "invalid bound value (expected a positive integer); falling back to the default",
79
+ )
80
+ return fallback
81
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * **プロセス内で 1 本**のセッション生成順シーケンス(cross-review minor 4 対応)。
3
+ *
4
+ * 【なぜ CLI ごとに持ってはいけないか】warm 境界の recency が同値になったときのタイブレークに
5
+ * seq を使う。cwd 単位の境界(per-cwd=K / N-cwd LRU)は同一 CLI 内でしか比較しないので
6
+ * CLI ごとのカウンタでも成立したが、**総 live 上限 M(warm-bounds-total.mjs)は claude と codex を
7
+ * 合算して「古い方から刈る」**ため、別カウンタだと CLI をまたいだ比較が成立しない。
8
+ * 例: 古い codex が seq=100 / 新しい claude が seq=2 のとき、recency 同値だと **新しい claude が
9
+ * 先に刈られる**。プロセス内で単一の単調増加にすれば、この比較が常に生成順と一致する。
10
+ *
11
+ * cwd 内のタイブレークは「単調増加であること」しか使っていないため、共有化しても挙動は変わらない
12
+ * (値の絶対値には意味が無い。⚠️ そのためテストで絶対値を assert しないこと。module-global は
13
+ * テスト間で値が持ち越される)。
14
+ */
15
+
16
+ let SESSION_SEQ = 0
17
+
18
+ /** 次のセッション seq(1 始まりの単調増加)を返す。 */
19
+ export function nextSessionSeq() {
20
+ SESSION_SEQ += 1
21
+ return SESSION_SEQ
22
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * 総 live 上限 M(既定 20): claude / codex を **合算** した live セッション数の天井。
3
+ *
4
+ * 【なぜ必要か】per-cwd の上限を 1 → K(既定 3)へ緩めたので、cwd 単位の境界(per-cwd=K ×
5
+ * N-cwd LRU)だけではマシン全体の総量が K 倍まで膨らみうる。方針は「総量は増やさず配分の
6
+ * 自由度だけ上げる」なので、cwd をまたいだ総量の天井をここで別に持つ。
7
+ *
8
+ * 【なぜ 1 モジュールで合算できるか】claude / codex の両ブリッジは同一プロセスに同居している
9
+ * (src/main.mjs の agent 起動時に両方 new される)ため、プロセス内で足し合わせれば実際の
10
+ * リソース消費(1 セッション ≈ 120-140MB)と一致する。
11
+ *
12
+ * 【CLI 分岐を持たない設計】各ブリッジが `listWarmCandidates(now)` という **CLI 非依存の共通
13
+ * アダプタ** を提供し、本モジュールはその配列だけを見る。ここに `kind === 'claude'` のような
14
+ * 分岐を入れると、CLI が増えるたびに刈り取りロジックが分岐して腐るため入れない。
15
+ * テストはアダプタ形状の stub 2 本で書ける。
16
+ */
17
+ import { readPositiveIntEnv, sanitizePositiveInt } from "./positive-int-env.mjs"
18
+
19
+ /** 総 live 上限 M(claude + codex 合算)。env で調整可能。ブリッジ側に重複定義しないこと。
20
+ *
21
+ * 【なぜ 20 か】2026-08-03 の実測で、同時 live は **17 本**(claude 13 / codex 4)だった。
22
+ * 内訳は distinct な (cwd, 種別) 16 バケットに対しほぼ 1 本ずつで、2 本あるのは 1 バケットのみ。
23
+ * つまり資源を消費しているのは「1 cwd に何本開くか(深さ)」ではなく「同時に開いている cwd の
24
+ * 数(広さ)」である。したがって:
25
+ * - K=3(深さ)を許しても今日の使い方の総量は変わらない
26
+ * - M=10 にすると **広さを切る**ことになり、導入した瞬間に 7 本が畳まれる(当初案を却下)
27
+ * 構造上の天井は、**旧 per-cwd=1 時代**が 40 本(cwd 数 N=20 × per-cwd 1 × ブリッジ 2)、
28
+ * **本 PR の per-cwd=K=3 では M が無ければ 120 本**(N=20 × K=3 × ブリッジ 2)。M=20 はその
29
+ * どちらより小さく、旧天井 40 の半分でありながら実測 17 本を切らない値になる(K を緩めても
30
+ * 総量は増やさない、という方針がこの M で担保される)。
31
+ * ⚠️ 畳んでも会話は消えない(resume で復帰する)が、温まった状態を失って切替が遅くなる。
32
+ * この待ち時間は 2026-07-31 に課題として挙がっている論点なので、安易に下げないこと。 */
33
+ export const WARM_TOTAL_MAX = readPositiveIntEnv("HUB_AGENT_WARM_TOTAL_MAX", 20)
34
+
35
+ /** 「超過しているが保護されていて刈れない」状態を info で出す最小間隔(既定 5 分)。
36
+ * state loop は 5 秒周期なので、抑制しないと同じ 1 行が 1 時間に約 720 件出る。 */
37
+ export const UNRESOLVED_LOG_INTERVAL_MS = 5 * 60 * 1000
38
+
39
+ /**
40
+ * @typedef {object} WarmCandidate
41
+ * @property {string} kind 'claude' | 'codex' 等(ログ用。刈り取り判断には使わない)
42
+ * @property {string} cwd 作業ディレクトリ(ログ用)
43
+ * @property {number} recency 最終活性(epoch ms。小さいほど古い = 先に刈る)
44
+ * @property {number} seq 生成順の単調増加値(recency 同値のタイブレーク。小さいほど古い)
45
+ * @property {boolean} protected 走行中ターン / pending / タスク実行中 等で不可侵か
46
+ * @property {(reason: string) => void} reap 実際に撤去する
47
+ */
48
+
49
+ /**
50
+ * 総 live 上限を適用する。**保護されているものは刈らない**(全部保護なら上限超過を許容する
51
+ * = 走行中を殺さない方を優先する。上限は次の tick で改めて試みられる)。
52
+ *
53
+ * ⚠️ **M はハードな資源上限ではない**(cross-review round2 minor)。保護(走行中ターン / 実行中の
54
+ * 背景タスク・コマンド)は必ず skip するので、保護が同時に多数付けば live 総数は M を超えたまま
55
+ * 増え続けうる。有界なのは **1 エントリあたりの保護時間**(各ブリッジの TTL)だけで、**同時に
56
+ * 保護されうる件数には K も N も M も効かない**。ここは「一時的な超過を明示的に受容する」設計で、
57
+ * 実際の超過量は戻り値の `liveTotal` / `unresolved`(と対応する info ログ)で観測する。
58
+ * 上限を守れているかの運用判断は、M という設定値ではなくその実測値を見ること。
59
+ *
60
+ * ⚠️ 将来「保護中でも刈る」ハード上限を足すなら、**実行中の作業を中断するという新しい失敗モード**
61
+ * が生まれる。ここに暗黙に足さないこと。足すなら victim の選び方・利用者への通知・再開可否を
62
+ * 別仕様として設計する。
63
+ *
64
+ * 【ログの抑制】state loop は 5 秒周期で回るため、「超過しているが全部保護されていて刈れない」
65
+ * 状態が続くと同じ info が 1 時間に約 720 件出る(旧実装は `reaped > 0 || overflow > 0` という
66
+ * **常に真の死んだ条件**だったので毎 tick 出ていた。overflow <= 0 は手前で return 済み)。
67
+ * そこで **実際に reap した tick は従来どおり info**、**unresolved だけの tick は「状態が変わった
68
+ * とき」か「一定間隔(既定 5 分)」でのみ**出す。
69
+ *
70
+ * ⚠️ 抑制の状態は **module-global に置かない**(複数インスタンス・並行テストが互いのログを
71
+ * 抑制してしまう)。呼び出し側が持つ `logState` オブジェクトに書く。渡さなければ抑制しない。
72
+ *
73
+ * @param {{ bridges?: Array<{ listWarmCandidates?: (now: number) => WarmCandidate[] }>,
74
+ * max?: number, now?: number, logger?: object,
75
+ * logState?: {lastUnresolved?: number, lastLoggedAtMs?: number} }} [opts]
76
+ * @returns {{reaped: number, unresolved: number, liveTotal: number}}
77
+ * reaped=実際に刈った数 / unresolved=保護されて刈れなかった超過ぶん / liveTotal=候補総数
78
+ */
79
+ export function enforceTotalLiveBound({
80
+ bridges = [],
81
+ max,
82
+ now = Date.now(),
83
+ logger,
84
+ logState,
85
+ } = {}) {
86
+ // ⚠️ 上限は正の整数(1.5 / Infinity / NaN は既定へ戻して warn)。positive-int-env.mjs 参照。
87
+ const limit = sanitizePositiveInt(max, WARM_TOTAL_MAX, {
88
+ logger,
89
+ label: "warmBounds.totalMax",
90
+ })
91
+ /** @type {WarmCandidate[]} */
92
+ const candidates = []
93
+ for (const bridge of bridges) {
94
+ if (!bridge || typeof bridge.listWarmCandidates !== "function") continue
95
+ try {
96
+ const list = bridge.listWarmCandidates(now)
97
+ if (Array.isArray(list)) {
98
+ for (const c of list) if (c) candidates.push(c)
99
+ }
100
+ } catch (err) {
101
+ logger?.warn?.({ err: err?.message }, "listWarmCandidates threw")
102
+ }
103
+ }
104
+ const overflow = candidates.length - limit
105
+ if (overflow <= 0) {
106
+ // 超過が解消した = 抑制状態をリセットする。次に新しく超過したときは即座に記録される。
107
+ if (logState) {
108
+ logState.lastUnresolved = 0
109
+ logState.lastLoggedAtMs = 0
110
+ }
111
+ return { reaped: 0, unresolved: 0, liveTotal: candidates.length }
112
+ }
113
+ // 古い順(recency 昇順、同値は seq 昇順)に、非保護だけを刈る。
114
+ const evictable = candidates
115
+ .filter((c) => !c.protected)
116
+ .sort((a, b) => (a.recency ?? 0) - (b.recency ?? 0) || (a.seq ?? 0) - (b.seq ?? 0))
117
+ let reaped = 0
118
+ for (const c of evictable) {
119
+ if (reaped >= overflow) break
120
+ try {
121
+ c.reap("warm-bounds:total")
122
+ reaped += 1
123
+ } catch (err) {
124
+ logger?.warn?.({ err: err?.message, kind: c.kind, cwd: c.cwd }, "total warm bound reap threw")
125
+ }
126
+ }
127
+ // 保護されて刈れなかったぶん(>0 なら一時的に上限超過を許容している)。
128
+ const unresolved = overflow - reaped
129
+ if (_shouldLog({ reaped, unresolved, now, logState })) {
130
+ logger?.info?.(
131
+ { live_total: candidates.length, max: limit, overflow, reaped, unresolved },
132
+ "warm bounds: total live limit applied",
133
+ )
134
+ if (logState) {
135
+ logState.lastUnresolved = unresolved
136
+ logState.lastLoggedAtMs = now
137
+ }
138
+ }
139
+ return { reaped, unresolved, liveTotal: candidates.length }
140
+ }
141
+
142
+ /** ログを出すか。reap した tick は常に出す。unresolved だけの tick は、状態が変わったときか
143
+ * 一定間隔が空いたときだけ出す(logState 未指定なら抑制しない)。 */
144
+ function _shouldLog({ reaped, unresolved, now, logState }) {
145
+ if (reaped > 0) return true
146
+ if (unresolved <= 0) return false
147
+ if (!logState) return true
148
+ if (logState.lastUnresolved !== unresolved) return true
149
+ return now - (logState.lastLoggedAtMs ?? 0) >= UNRESOLVED_LOG_INTERVAL_MS
150
+ }