@cocorograph/hub-agent 0.7.79 → 0.7.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/claude-stream-bridge.mjs +208 -55
- package/src/codex-stream-bridge.mjs +509 -115
- package/src/main.mjs +31 -4
- package/src/service-install.mjs +5 -37
- package/templates/co.cocorograph.hub-agent.plist +1 -1
- package/templates/hub-agent.service +1 -1
|
@@ -21,10 +21,14 @@
|
|
|
21
21
|
* (新 app-server を起動しない)。browser の WS 再接続で stream_id が変わっても、fresh 直後
|
|
22
22
|
* (rollout 0件) の会話も含めて復元できる。詳細は attach() の docstring 参照。
|
|
23
23
|
*
|
|
24
|
+
* ## 多端末購読 (案B / T06542)
|
|
25
|
+
* 複数ブラウザタブでの **同時** 同一スレッド共有を実装済み (Claude の多端末共有と対称)。
|
|
26
|
+
* session は購読集合 `streamIds` を持ち、reattach は「所有権付け替え」ではなく「購読者追加」。
|
|
27
|
+
* どの購読端末からの input/permission も解決でき、配信は Hub backend が thread_id グループ
|
|
28
|
+
* broadcast で行う (agent はイベントに thread_id を載せるだけ)。詳細は attach()/_tryReattach()/
|
|
29
|
+
* detach()/gcDeadTerminals() の docstring 参照。旧 Slice 2 の「単一勝者のみ (同時共有は非対応)」は撤廃。
|
|
30
|
+
*
|
|
24
31
|
* Slice 2 のスコープ外 (意図的に見送り、後続スライスで追加する):
|
|
25
|
-
* - 複数ブラウザタブでの **同時** 同一スレッド共有 (Claude の HUB_AGENT_CHAT_SHARED 相当)。
|
|
26
|
-
* in-memory reattach は「1 つの持ち主が WS を張り直す」ケース (単一勝者) であり、複数タブが
|
|
27
|
-
* 同時に同一スレッドを操作する共有はまだ扱わない。
|
|
28
32
|
* - 画像/添付入力 (テキストのみ)。
|
|
29
33
|
* - acceptForSession や execpolicy/network amendment 等の高度な承認 decision
|
|
30
34
|
* (permissionReply は decision をそのまま素通しするので呼び出し側が組み立てる)。
|
|
@@ -84,10 +88,21 @@ function siblingEnvFor(cwd) {
|
|
|
84
88
|
const IDLE_DETACH_TTL_MS =
|
|
85
89
|
Number(process.env.HUB_AGENT_CODEX_IDLE_DETACH_TTL_MS) || 7 * 24 * 60 * 60 * 1000
|
|
86
90
|
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
+
// 子⑤ (T06544): warm 保持の上限を「マシン全体の総セッション数」から「各 cwd 1 本 × 同時 warm
|
|
92
|
+
// 保持する cwd 数 N」へ再定義(親 T06539「1 cwd 1 本化」に整合)。旧 WARM_POOL_MAX(総数 LRU=5)は
|
|
93
|
+
// 廃止し、境界を cwd 単位で定義する。1 セッション ≈ 120-140MB(node ラッパー + 本体 + MCP 子)
|
|
94
|
+
// なので既定 20 cwd(~2.4-2.8GB 上限)。端末リソースで無理なら env で下げる。
|
|
95
|
+
// 後方互換: 旧 HUB_AGENT_CODEX_WARM_POOL_MAX を設定していても新 cwd 上限として解釈する。
|
|
96
|
+
const WARM_CWD_MAX =
|
|
97
|
+
Number(process.env.HUB_AGENT_WARM_CWD_MAX) ||
|
|
98
|
+
Number(process.env.HUB_AGENT_CODEX_WARM_POOL_MAX) ||
|
|
99
|
+
20
|
|
100
|
+
|
|
101
|
+
// 子⑤ (T06544, codex-review round5 Finding 1): warm 境界の「最新 1 本を残す」判定で recency
|
|
102
|
+
// (lastActivityMs) が同一ミリ秒になったときのタイブレーク用の単調増加シーケンス。生成順が後 =
|
|
103
|
+
// 値が大きい = 新しいので、per-cwd=1 のタイでは **必ず新しく attach したセッションを残す**
|
|
104
|
+
// (旧実装は安定ソートの挿入順で旧セッションが残り、新規 attach が即 reap される非決定性があった)。
|
|
105
|
+
let CODEX_SESSION_SEQ = 0
|
|
91
106
|
|
|
92
107
|
// stuck turn 検出の閾値。activeTurnId が立っているのに app-server 通知がこの時間途絶えた
|
|
93
108
|
// セッションは「ハングしたターン」とみなし protected から外して reap する(healthy な長ターンは
|
|
@@ -98,6 +113,21 @@ const STUCK_TURN_TTL_MS =
|
|
|
98
113
|
Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) ||
|
|
99
114
|
60 * 60 * 1000
|
|
100
115
|
|
|
116
|
+
// 案B (T06542): 死端末 GC の TTL。多端末購読で、応答を返さなくなった(クラッシュ / タブ強制終了で
|
|
117
|
+
// codex.detach を送れなかった)購読端末を購読集合から掃除する閾値。claude-stream-bridge.mjs の
|
|
118
|
+
// DEAD_TERMINAL_TTL_MS(既定 1h)と同型・同既定。stuck turn TTL とは別軸(あちらはターン固着、
|
|
119
|
+
// こちらは端末の無応答)だが既定は揃える。
|
|
120
|
+
const DEAD_TERMINAL_TTL_MS =
|
|
121
|
+
Number(process.env.HUB_AGENT_CODEX_DEAD_TERMINAL_TTL_MS) ||
|
|
122
|
+
Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) ||
|
|
123
|
+
60 * 60 * 1000
|
|
124
|
+
|
|
125
|
+
// 案B (T06542, codex-review round3 major#2): turn/start RPC 成功後に turn/started 通知が来るのを待つ
|
|
126
|
+
// 上限。これを超えたら「ターンが正しく始まらなかった」とみなし、pump が待機中の queue を reject して
|
|
127
|
+
// 状態を解除する(turn/started/completed/exit が来ないケースで promise が永久宙吊りになるのを防ぐ)。
|
|
128
|
+
const TURN_START_TIMEOUT_MS =
|
|
129
|
+
Number(process.env.HUB_AGENT_CODEX_TURN_START_TIMEOUT_MS) || 30 * 1000
|
|
130
|
+
|
|
101
131
|
const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
|
|
102
132
|
|
|
103
133
|
// T06499 (2026-07-18): 巨大スレッドの resume ガード。長期運用したスレッドの rollout は
|
|
@@ -271,8 +301,10 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
271
301
|
logger,
|
|
272
302
|
clientFactory,
|
|
273
303
|
detachTtlMs,
|
|
274
|
-
|
|
304
|
+
warmCwdMax,
|
|
275
305
|
stuckTurnTtlMs,
|
|
306
|
+
deadTerminalTtlMs,
|
|
307
|
+
turnStartTimeoutMs,
|
|
276
308
|
rolloutPresence,
|
|
277
309
|
historyMaxItems,
|
|
278
310
|
historyMaxBytes,
|
|
@@ -283,10 +315,15 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
283
315
|
this._clientFactory = clientFactory || ((opts) => new CodexAppServerClient(opts))
|
|
284
316
|
// Stage 5: warm 保持の idle TTL(既定 7 日)。テストから短縮できるよう注入可能。
|
|
285
317
|
this._detachTtlMs = detachTtlMs ?? IDLE_DETACH_TTL_MS
|
|
286
|
-
//
|
|
287
|
-
|
|
318
|
+
// 子⑤ (T06544): 同時 warm 保持する cwd 数の上限(既定 20)。各 cwd は 1 本に畳む。
|
|
319
|
+
// テストから小さい値を注入可能。
|
|
320
|
+
this._warmCwdMax = warmCwdMax ?? WARM_CWD_MAX
|
|
288
321
|
// Stage 5: stuck turn 検出 TTL(既定 1h)。テストから短縮可能。
|
|
289
322
|
this._stuckTurnTtlMs = stuckTurnTtlMs ?? STUCK_TURN_TTL_MS
|
|
323
|
+
// 案B (T06542): 死端末 GC の TTL(既定 1h)。テストから短縮可能。
|
|
324
|
+
this._deadTerminalTtlMs = deadTerminalTtlMs ?? DEAD_TERMINAL_TTL_MS
|
|
325
|
+
// round3 major#2: turn/start 後に turn/started を待つ上限(既定 30s)。テストから短縮可能。
|
|
326
|
+
this._turnStartTimeoutMs = turnStartTimeoutMs ?? TURN_START_TIMEOUT_MS
|
|
290
327
|
// #4 失敗分類の rollout 存在チェック (tri-state。テストから注入可能)。
|
|
291
328
|
this._rolloutPresence = rolloutPresence || codexRolloutPresence
|
|
292
329
|
// T06499: 履歴 hydrate の上限 (テストから小さい値を注入可能)。
|
|
@@ -381,8 +418,22 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
381
418
|
// unhandled rejection を作らず、複数の reconnect が同じ promise を安全に await できる)。
|
|
382
419
|
let settleReady
|
|
383
420
|
const session = {
|
|
421
|
+
// primary stream_id: legacy な単一値 emit / ログ用。案B では最新購読端末へ寄せる
|
|
422
|
+
// (streamIds が真の購読者集合)。app-server 通知や emit は都度 session.stream_id を読む。
|
|
384
423
|
stream_id,
|
|
424
|
+
// 子⑤ round5 Finding 1: 生成順の単調増加シーケンス(warm 境界のタイブレーク用。値が大きい =
|
|
425
|
+
// 新しい)。recency 同値時に per-cwd=1 が新しいセッションを残せるようにする。
|
|
426
|
+
seq: (CODEX_SESSION_SEQ += 1),
|
|
385
427
|
cwd,
|
|
428
|
+
// 案B (T06542): 多端末購読集合。この live session を購読中の全端末 stream_id。reattach は
|
|
429
|
+
// 「所有権付け替え」ではなく「購読者追加」。detach で当該端末を外し、最後の1台が外れたら
|
|
430
|
+
// warm 保持(reconcile が LRU/idle TTL/stuck で reap)。sessions Map は各購読 stream_id →
|
|
431
|
+
// この session の多対一。distinct session 走査は必ず _liveSessions() で dedup する。
|
|
432
|
+
streamIds: new Set([stream_id]),
|
|
433
|
+
// 死端末 GC 用の端末別最終活性。browser→agent アクション(input/permission/interrupt/
|
|
434
|
+
// keepalive)で更新し、応答を返さなくなった購読端末を gcDeadTerminals が掃除する
|
|
435
|
+
// (claude-stream-bridge.mjs の lastActivityByStream と同型)。
|
|
436
|
+
lastActivityByStream: new Map([[stream_id, Date.now()]]),
|
|
386
437
|
// #1 in-memory reattach のキー。attach 試行を跨いで安定 (WS 再接続で stream_id は変わる)。
|
|
387
438
|
attachId: attachId || null,
|
|
388
439
|
client,
|
|
@@ -393,6 +444,26 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
393
444
|
// この session を引き当て _tryReattach の session.ready 合流に入り、二重 app-server を作らない。
|
|
394
445
|
resumeThreadId: resumeThreadId || null,
|
|
395
446
|
activeTurnId: null,
|
|
447
|
+
// 案B (T06542, codex-review round3 major#1): 同時入力を **session 単位の単一 FIFO + 単一 pump**で
|
|
448
|
+
// 直列化し、二重 turn/start・queue 追い越し・並行 steer を構造的に排除する。旧 turnStarting/
|
|
449
|
+
// pendingInputs の二重路 (queue 経路 + 直通経路) が overtaking と並行リトライ二重ターンの温床だった。
|
|
450
|
+
/** @type {Array<{input: object[], model?: string, effort?: string, resolve: Function,
|
|
451
|
+
* reject: Function, retriedAsStart?: boolean}>} round3 major#1: **session 単位の単一 FIFO**。
|
|
452
|
+
* 全 input() はここに積まれ、単一 pump (_pumpInputs) が **1 件ずつ直列**に処理する。直通経路を
|
|
453
|
+
* 廃したので、drain 中の新規 input が queue を追い越したり、並行 turn/start・steer を発行して
|
|
454
|
+
* 二重ターンになる事故が起きない。各 promise は **実 delivery (turn/start か turn/steer の RPC)**で
|
|
455
|
+
* settle する (main.mjs が codex.input.ack/nack を実配達に合わせて出せる)。 */
|
|
456
|
+
inputQueue: [],
|
|
457
|
+
/** round3 major#1: pump が走行中か (単一 pump を保証する singleflight フラグ)。 */
|
|
458
|
+
pumping: false,
|
|
459
|
+
/** @type {{resolve: Function, timer: any} | null} round3 major#2: turn/start 後に turn/started
|
|
460
|
+
* (activeTurnId 確定) を待つ barrier の待機者。timeout 付きでハングを防ぐ。 */
|
|
461
|
+
_turnActiveWaiter: null,
|
|
462
|
+
/** round4 major#2: turn/started 通知ごとに +1 する世代カウンタ。pump は turn/start dispatch 前に
|
|
463
|
+
* 値を控え、応答後に「world が前進したか (turn が始まった/更に完了した)」を activeTurnId だけでなく
|
|
464
|
+
* この前進でも判定する。fast-turn (start 応答と started+completed が同一 chunk で pump 再開前に
|
|
465
|
+
* 処理される) で activeTurnId=null に戻っていても、世代前進で検知して誤 timeout を防ぐ。 */
|
|
466
|
+
turnGeneration: 0,
|
|
396
467
|
// request_id -> { turnId, method, params }。major#1: reattach で新 stream へ未応答の承認要求を
|
|
397
468
|
// 再送できるよう、元の method/params も保持する (Claude の _permissionResolvers と同型)。
|
|
398
469
|
pendingApprovals: new Map(),
|
|
@@ -489,10 +560,11 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
489
560
|
},
|
|
490
561
|
"codex session attached",
|
|
491
562
|
)
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
|
|
563
|
+
// 子⑤ (T06544): genuine attach で 1 本増えたので warm 境界(per-cwd=1 / N-cwd LRU)を即時
|
|
564
|
+
// 適用する(次の reconnect まで ~5s 待たず、同一 cwd の重複や cwd 数超過が居座るのを防ぐ)。
|
|
565
|
+
// 新セッションはたった今 touch 済み = 各判定で最新なので退避対象にならず、最も古い非 protected
|
|
566
|
+
// が落ちる。
|
|
567
|
+
this._reconcileWarmBounds(Date.now(), "warm-bounds-on-attach")
|
|
496
568
|
return {
|
|
497
569
|
resuming,
|
|
498
570
|
threadId: session.threadId,
|
|
@@ -579,32 +651,41 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
579
651
|
* 付け替えられなければ null を返し、呼び出し側 attach() は通常経路へフォールスルーする。
|
|
580
652
|
*/
|
|
581
653
|
async _tryReattach(session, newStreamId) {
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
654
|
+
// 案B (T06542): reattach は「所有権付け替え」ではなく **購読者追加**。旧 stream を Map から
|
|
655
|
+
// 消さず、newStreamId を streamIds へ足して両方が同一 live session を購読する状態を作る
|
|
656
|
+
// (どの端末の input/permission/interrupt も解決でき、backend が thread group へ fan-out する)。
|
|
657
|
+
// これにより「2 本目の app-server を立てず、旧端末も生かしたまま」新端末を相乗りさせる。
|
|
586
658
|
// 戻り値 status:
|
|
587
|
-
// "reattached" -
|
|
588
|
-
// "superseded" -
|
|
589
|
-
//
|
|
659
|
+
// "reattached" - newStreamId を購読者として追加した (info 同梱)。
|
|
660
|
+
// "superseded" - newStreamId が既に別 session を保持している異常時のみ (通常は起きない。
|
|
661
|
+
// attach() が :318 で has(stream_id) を弾くため)。呼び出し側は通常経路に
|
|
662
|
+
// 落とさず失敗させる (二重 thread を作らない)。
|
|
590
663
|
// "unavailable"- session が死んだ / first の thread 確定に失敗 (ライブ session 無し)。
|
|
591
664
|
// 呼び出し側は通常経路へフォールスルーしてよい。
|
|
592
|
-
const originalStreamId = session.stream_id
|
|
593
665
|
let threadId = session.threadId
|
|
594
666
|
if (!threadId && session.ready) threadId = await session.ready
|
|
595
667
|
// first の thread op が失敗 / session が死んだ = ライブ session はもう無い。
|
|
596
668
|
if (!threadId || session.dead) return { status: "unavailable" }
|
|
597
|
-
|
|
598
|
-
if (
|
|
599
|
-
|
|
600
|
-
|
|
669
|
+
const existingForNew = this.sessions.get(newStreamId)
|
|
670
|
+
if (existingForNew && existingForNew !== session) return { status: "superseded" }
|
|
671
|
+
// 購読者追加: streamIds / 端末別活性 / Map を更新し、primary を最新端末へ寄せる
|
|
672
|
+
// (legacy 単一値 emit / ログ用。真の配信は backend の thread group fan-out が担う)。
|
|
673
|
+
const now = Date.now()
|
|
674
|
+
session.streamIds.add(newStreamId)
|
|
675
|
+
session.lastActivityByStream.set(newStreamId, now)
|
|
676
|
+
this.sessions.set(newStreamId, session)
|
|
601
677
|
session.stream_id = newStreamId
|
|
602
678
|
session.detachRequested = false
|
|
603
|
-
session.lastActivityMs =
|
|
604
|
-
this.sessions.set(newStreamId, session)
|
|
679
|
+
session.lastActivityMs = now
|
|
605
680
|
this.logger?.info?.(
|
|
606
|
-
{
|
|
607
|
-
|
|
681
|
+
{
|
|
682
|
+
stream_id: newStreamId,
|
|
683
|
+
thread_id: threadId,
|
|
684
|
+
cwd: session.cwd,
|
|
685
|
+
reattached: true,
|
|
686
|
+
subscribers: session.streamIds.size,
|
|
687
|
+
},
|
|
688
|
+
"codex session reattached (multi-terminal subscribe)",
|
|
608
689
|
)
|
|
609
690
|
// major#1: 新 stream へ未応答の承認要求を再送する (Claude の _replayPendingPermissions と同型)。
|
|
610
691
|
// 承認要求は app-server との in-flight callback で、履歴 hydrate には含まれない。旧 stream 宛に
|
|
@@ -719,9 +800,38 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
719
800
|
})
|
|
720
801
|
}
|
|
721
802
|
|
|
722
|
-
/**
|
|
723
|
-
|
|
724
|
-
|
|
803
|
+
/**
|
|
804
|
+
* reconcile 用の最終活性更新。browser 入力 / app-server 通知の両方で呼ぶ。
|
|
805
|
+
* 案B: stream_id を伴う browser→agent アクション(input/permission/interrupt/keepalive)では
|
|
806
|
+
* **端末別の活性(lastActivityByStream)も更新**し、gcDeadTerminals が「応答を返さない購読端末」
|
|
807
|
+
* だけを掃除できるようにする。app-server 通知(stream_id なし)は session 全体の活性のみ更新する。
|
|
808
|
+
*/
|
|
809
|
+
_touch(session, stream_id) {
|
|
810
|
+
if (!session) return
|
|
811
|
+
const now = Date.now()
|
|
812
|
+
session.lastActivityMs = now
|
|
813
|
+
if (stream_id && session.streamIds?.has(stream_id)) {
|
|
814
|
+
session.lastActivityByStream.set(stream_id, now)
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* 案B: sessions Map は各購読 stream_id → session の多対一なので、distinct な live session を
|
|
820
|
+
* 走査するときは必ずこれで dedup する(同一 session を購読端末数ぶん重複カウントしない)。
|
|
821
|
+
* @returns {object[]} 非 dead な distinct session の配列
|
|
822
|
+
*/
|
|
823
|
+
_liveSessions() {
|
|
824
|
+
return Array.from(new Set(this.sessions.values())).filter((s) => !s.dead)
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** 案B: この session を指す全購読 stream_id キーを sessions Map から外す(reap / exit 共通)。 */
|
|
828
|
+
_dropAllStreamKeys(session) {
|
|
829
|
+
const ids = session.streamIds ? Array.from(session.streamIds) : []
|
|
830
|
+
for (const sid of ids) {
|
|
831
|
+
if (this.sessions.get(sid) === session) this.sessions.delete(sid)
|
|
832
|
+
}
|
|
833
|
+
// primary が streamIds に含まれない異常時の保険。
|
|
834
|
+
if (this.sessions.get(session.stream_id) === session) this.sessions.delete(session.stream_id)
|
|
725
835
|
}
|
|
726
836
|
|
|
727
837
|
_onNotification(session, msg) {
|
|
@@ -733,11 +843,18 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
733
843
|
switch (msg.method) {
|
|
734
844
|
case "turn/started":
|
|
735
845
|
session.activeTurnId = msg.params?.turn?.id || null
|
|
846
|
+
// round4 major#2: 世代を前進させる (pump が fast-turn を activeTurnId だけでなく世代前進でも検知)。
|
|
847
|
+
session.turnGeneration += 1
|
|
848
|
+
// round3 major#2: 起動確定 → pump の turn-active barrier を解除する (次 item が steer へ進む)。
|
|
849
|
+
this._settleTurnActiveWaiter(session)
|
|
736
850
|
// 会話ターン境界で「新しさ」を更新(閲覧 keepalive では更新しない)。
|
|
737
851
|
session.lastTurnAtMs = Date.now()
|
|
738
852
|
break
|
|
739
853
|
case "turn/completed":
|
|
740
854
|
session.activeTurnId = null
|
|
855
|
+
// round3 major#2: turn/started を取りこぼしたまま turn/completed が来た異常時も barrier が
|
|
856
|
+
// 固着しないよう解除する (pump が次 item を fresh に turn/start できる)。
|
|
857
|
+
this._settleTurnActiveWaiter(session)
|
|
741
858
|
session.lastTurnAtMs = Date.now()
|
|
742
859
|
this._autoResolveStalePendingApprovals(session, msg.params?.turn?.id)
|
|
743
860
|
// Stage 5: turn 完了後の reap 予約は不要。以降 idle になれば周期 reconcile が
|
|
@@ -810,7 +927,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
810
927
|
_onClientExit(session, info) {
|
|
811
928
|
if (session.dead) return
|
|
812
929
|
session.dead = true
|
|
813
|
-
|
|
930
|
+
// round3: app-server が死んだら barrier を解除し queue を reject する(promise 宙吊り防止)。
|
|
931
|
+
this._settleTurnActiveWaiter(session)
|
|
932
|
+
this._drainRejectQueue(session, { reason: "queue_dead" })
|
|
933
|
+
// 案B: app-server が死んだら全購読 stream_id キーを Map から外す(1 端末分だけでは
|
|
934
|
+
// 他の購読キーが dangling で残る)。
|
|
935
|
+
this._dropAllStreamKeys(session)
|
|
814
936
|
// app-server の終了は必ず記録する。ターン実行中の exit は「rollout に output 欠落の
|
|
815
937
|
// custom_tool_call を残した可能性がある」= thread 汚染の予兆なので warn で出す
|
|
816
938
|
// (codex_core の `Custom tool call output is missing for call id: ...` の発生源)。
|
|
@@ -861,55 +983,177 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
861
983
|
const session = this._require(stream_id)
|
|
862
984
|
// Stage 5: _touch で lastActivityMs を更新 = LRU 先頭へ。ターン開始で activeTurnId が
|
|
863
985
|
// 立ち、reconcile は healthy busy を protected 扱いにするので実行中に kill されない。
|
|
864
|
-
|
|
986
|
+
// 案B: stream_id を渡し端末別活性も更新(死端末 GC が入力継続端末を掃除しない)。
|
|
987
|
+
this._touch(session, stream_id)
|
|
865
988
|
const input = userInputFromMessage(message)
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
989
|
+
// round3 major#1: 全 input を **session 単位の単一 FIFO** に積み、単一 pump に処理させる。
|
|
990
|
+
// promise は pump が実 delivery で settle する (即 resolve しない)。
|
|
991
|
+
return new Promise((resolve, reject) => {
|
|
992
|
+
session.inputQueue.push({ input, model, effort, resolve, reject, retriedAsStart: false })
|
|
993
|
+
void this._pumpInputs(session)
|
|
994
|
+
})
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* round3 major#1 (案B / T06542): session 単位の単一 FIFO pump。**1 件ずつ直列**に処理し、
|
|
999
|
+
* 並行 turn/start・turn/steer や queue の追い越しを構造的に排除する (singleflight)。
|
|
1000
|
+
* - 非 busy (activeTurnId 無し): turn/start で新ターン。成功後、次を steer にするため
|
|
1001
|
+
* turn/started (activeTurnId 確定) を **timeout 付き**で待つ (round3 major#2: ハング防止)。
|
|
1002
|
+
* - busy (activeTurnId 有り): turn/steer で追い打ち。
|
|
1003
|
+
* - turn/steer 失敗 (expectedTurnId 不一致など): activeTurnId を捨て、同 item を **1 回だけ**
|
|
1004
|
+
* turn/start にフォールバックして自己修復する (旧 self-heal を FIFO 内で継続。並行はしない)。
|
|
1005
|
+
* 各 item の promise は実 delivery の RPC 結果で settle する。
|
|
1006
|
+
*/
|
|
1007
|
+
async _pumpInputs(session) {
|
|
1008
|
+
if (session.pumping) return
|
|
1009
|
+
session.pumping = true
|
|
1010
|
+
try {
|
|
1011
|
+
while (session.inputQueue.length > 0) {
|
|
1012
|
+
if (session.dead) {
|
|
1013
|
+
this._drainRejectQueue(session, { reason: "queue_dead" })
|
|
887
1014
|
return
|
|
888
|
-
} catch (retryErr) {
|
|
889
|
-
// 呼び出し元 (main.mjs の codex.input dispatch) が既存の .catch() で
|
|
890
|
-
// codex.error を送出する経路に reason を乗せて届ける (bridge 側で二重に
|
|
891
|
-
// 'error' イベントを emit すると frontend へ codex.error が二重送出される
|
|
892
|
-
// ため、ここでは throw のみに留める)。
|
|
893
|
-
const reason = "turn_mismatch_retry_failed"
|
|
894
|
-
throw Object.assign(
|
|
895
|
-
new Error(
|
|
896
|
-
`turn/steer failed (${err.message}) and turn/start retry also failed: ${retryErr.message}`,
|
|
897
|
-
),
|
|
898
|
-
{ reason, cause: retryErr },
|
|
899
|
-
)
|
|
900
1015
|
}
|
|
1016
|
+
const item = session.inputQueue[0]
|
|
1017
|
+
// round4 major#3: 試行種別 (steer/start) を **await の前に** 確定する。await 中に
|
|
1018
|
+
// turn/completed が来て session.activeTurnId が null に戻ると、catch 時点の値では steer 試行が
|
|
1019
|
+
// start と誤判定され、self-heal リトライがスキップされて item が誤 reject される。
|
|
1020
|
+
const attemptedSteer = !!session.activeTurnId
|
|
1021
|
+
try {
|
|
1022
|
+
if (attemptedSteer) {
|
|
1023
|
+
await session.client.request("turn/steer", {
|
|
1024
|
+
threadId: session.threadId,
|
|
1025
|
+
expectedTurnId: session.activeTurnId,
|
|
1026
|
+
input: item.input,
|
|
1027
|
+
})
|
|
1028
|
+
session.inputQueue.shift()
|
|
1029
|
+
item.resolve()
|
|
1030
|
+
} else {
|
|
1031
|
+
// round4 major#2: turn/start dispatch の **前に** 世代を控える。start 応答後に
|
|
1032
|
+
// turn/started(+completed) が pump 再開前に処理されても、世代前進で「ターンが始まった」を
|
|
1033
|
+
// 検知でき、誤 timeout しない。
|
|
1034
|
+
const genBefore = session.turnGeneration
|
|
1035
|
+
const params = { threadId: session.threadId, input: item.input }
|
|
1036
|
+
if (item.model) params.model = item.model
|
|
1037
|
+
if (item.effort) params.effort = item.effort
|
|
1038
|
+
await session.client.request("turn/start", params)
|
|
1039
|
+
session.inputQueue.shift()
|
|
1040
|
+
item.resolve()
|
|
1041
|
+
// 次の item を steer にするため turn/started (= 世代前進) を待つ。timeout でハングを防ぐ。
|
|
1042
|
+
// timeout したら「ターンが正しく始まらなかった」とみなし残り queue を reject して打ち切る。
|
|
1043
|
+
const becameActive = await this._waitTurnActive(session, genBefore)
|
|
1044
|
+
if (!becameActive) {
|
|
1045
|
+
this._drainRejectQueue(session, { reason: "turn_start_timeout" })
|
|
1046
|
+
return
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
} catch (err) {
|
|
1050
|
+
if (attemptedSteer && !item.retriedAsStart) {
|
|
1051
|
+
// turn/steer 失敗 → 自己修復: activeTurnId を捨て、同 item を turn/start で 1 回だけリトライ
|
|
1052
|
+
// (shift しない = 同 item を次ループの start 経路で再処理)。並行はしない (単一 pump)。
|
|
1053
|
+
// 元の steer エラーを stash して、リトライも失敗したとき合成メッセージを作れるようにする。
|
|
1054
|
+
session.activeTurnId = null
|
|
1055
|
+
item.retriedAsStart = true
|
|
1056
|
+
item.priorSteerError = err instanceof Error ? err : new Error(String(err))
|
|
1057
|
+
continue
|
|
1058
|
+
}
|
|
1059
|
+
// start 失敗 or リトライ済み steer 失敗 → この item を reject。activeTurnId は捨てる
|
|
1060
|
+
// (ターンは始まっていない/不明)。次の item は fresh に turn/start する。
|
|
1061
|
+
session.inputQueue.shift()
|
|
1062
|
+
session.activeTurnId = null
|
|
1063
|
+
let e
|
|
1064
|
+
if (item.retriedAsStart && item.priorSteerError) {
|
|
1065
|
+
// steer→start リトライがともに失敗: 合成メッセージ + reason (main.mjs の classifyCodexInputError
|
|
1066
|
+
// が reason を優先採用。従来の単一 input() 実装と同じ診断を保つ)。
|
|
1067
|
+
e = Object.assign(
|
|
1068
|
+
new Error(
|
|
1069
|
+
`turn/steer failed (${item.priorSteerError.message}) and turn/start retry also failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1070
|
+
),
|
|
1071
|
+
{ reason: "turn_mismatch_retry_failed", cause: err },
|
|
1072
|
+
)
|
|
1073
|
+
} else {
|
|
1074
|
+
e = err instanceof Error ? err : new Error(String(err))
|
|
1075
|
+
if (!e.reason) e.reason = "start_error"
|
|
1076
|
+
}
|
|
1077
|
+
item.reject(e)
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
} finally {
|
|
1081
|
+
session.pumping = false
|
|
1082
|
+
// pumping=false 直後の enqueue 競合窓を拾い直す (取りこぼし防止)。再帰は 1 段のみ。
|
|
1083
|
+
if (session.inputQueue.length > 0 && !session.dead) {
|
|
1084
|
+
void this._pumpInputs(session)
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* round3 major#2 + round4 major#2: turn/start 後に「ターンが始まった」ことを timeout 付きで待つ
|
|
1091
|
+
* barrier。activeTurnId が立っているか、または **turn/start dispatch 前に控えた世代 genBefore から
|
|
1092
|
+
* 世代が前進している** (fast-turn で started+completed 済み) なら即 true を返し、誤 timeout しない。
|
|
1093
|
+
* @param {object} session
|
|
1094
|
+
* @param {number} [genBefore] turn/start dispatch 直前の session.turnGeneration
|
|
1095
|
+
* @returns {Promise<boolean>} ターンが始まったら true、timeout したら false。
|
|
1096
|
+
*/
|
|
1097
|
+
_waitTurnActive(session, genBefore = -1) {
|
|
1098
|
+
if (session.activeTurnId || session.turnGeneration > genBefore) return Promise.resolve(true)
|
|
1099
|
+
return new Promise((resolve) => {
|
|
1100
|
+
const timer = setTimeout(() => {
|
|
1101
|
+
if (session._turnActiveWaiter && session._turnActiveWaiter.timer === timer) {
|
|
1102
|
+
session._turnActiveWaiter = null
|
|
1103
|
+
}
|
|
1104
|
+
resolve(false)
|
|
1105
|
+
}, this._turnStartTimeoutMs)
|
|
1106
|
+
// minor (round4): 通知を出さない単体テスト等でプロセスを timeout 分保持しないよう unref する。
|
|
1107
|
+
if (typeof timer.unref === "function") timer.unref()
|
|
1108
|
+
// 既存 waiter があれば置換前に false 解決しておく (通常は無い)。
|
|
1109
|
+
if (session._turnActiveWaiter) {
|
|
1110
|
+
try {
|
|
1111
|
+
clearTimeout(session._turnActiveWaiter.timer)
|
|
1112
|
+
} catch {
|
|
1113
|
+
/* noop */
|
|
1114
|
+
}
|
|
1115
|
+
session._turnActiveWaiter.resolve(false)
|
|
901
1116
|
}
|
|
1117
|
+
session._turnActiveWaiter = { resolve: () => resolve(true), timer }
|
|
1118
|
+
})
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** round3 major#2: turn/started / completed / exit / 死亡で待機中の barrier を解除する。 */
|
|
1122
|
+
_settleTurnActiveWaiter(session) {
|
|
1123
|
+
const w = session._turnActiveWaiter
|
|
1124
|
+
if (!w) return
|
|
1125
|
+
session._turnActiveWaiter = null
|
|
1126
|
+
try {
|
|
1127
|
+
clearTimeout(w.timer)
|
|
1128
|
+
} catch {
|
|
1129
|
+
/* noop */
|
|
1130
|
+
}
|
|
1131
|
+
w.resolve()
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* round3: FIFO の未処理 item を一括 reject する (session 死亡 / turn_start timeout で pump が
|
|
1136
|
+
* 続行できないとき。ack も nack も返らない配達喪失を防ぐ)。
|
|
1137
|
+
*/
|
|
1138
|
+
_drainRejectQueue(session, cause) {
|
|
1139
|
+
if (!session || !Array.isArray(session.inputQueue) || session.inputQueue.length === 0) return
|
|
1140
|
+
const pending = session.inputQueue
|
|
1141
|
+
session.inputQueue = []
|
|
1142
|
+
const reason = cause?.reason || "queue_dead"
|
|
1143
|
+
for (const { reject } of pending) {
|
|
1144
|
+
reject(
|
|
1145
|
+
Object.assign(new Error("codex input dropped: turn could not be delivered"), {
|
|
1146
|
+
reason,
|
|
1147
|
+
cause,
|
|
1148
|
+
}),
|
|
1149
|
+
)
|
|
902
1150
|
}
|
|
903
|
-
const params = { threadId: session.threadId, input }
|
|
904
|
-
if (model) params.model = model
|
|
905
|
-
if (effort) params.effort = effort
|
|
906
|
-
await session.client.request("turn/start", params)
|
|
907
1151
|
}
|
|
908
1152
|
|
|
909
1153
|
/** @returns {Promise<void>} */
|
|
910
1154
|
async interrupt({ stream_id }) {
|
|
911
1155
|
const session = this._require(stream_id)
|
|
912
|
-
this._touch(session)
|
|
1156
|
+
this._touch(session, stream_id)
|
|
913
1157
|
if (!session.activeTurnId) return
|
|
914
1158
|
await session.client.request("turn/interrupt", {
|
|
915
1159
|
threadId: session.threadId,
|
|
@@ -920,7 +1164,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
920
1164
|
/** サーバー発の承認/入力要求への応答。result の shape は method ごとに呼び出し側が組み立てる。 */
|
|
921
1165
|
permissionReply({ stream_id, request_id, result }) {
|
|
922
1166
|
const session = this._require(stream_id)
|
|
923
|
-
|
|
1167
|
+
// 案B エッジ②: 先着 1 応答で解決。stream_id を渡し応答端末の活性を更新(死端末 GC 対象外に)。
|
|
1168
|
+
this._touch(session, stream_id)
|
|
924
1169
|
if (!session.pendingApprovals.has(request_id)) {
|
|
925
1170
|
this.logger?.warn?.({ stream_id, request_id }, "codex permissionReply: unknown request_id (ignored)")
|
|
926
1171
|
return
|
|
@@ -941,18 +1186,48 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
941
1186
|
keepalive({ stream_id }) {
|
|
942
1187
|
const session = this.sessions.get(stream_id)
|
|
943
1188
|
if (!session || session.dead) return false
|
|
944
|
-
this._touch(session)
|
|
1189
|
+
this._touch(session, stream_id)
|
|
945
1190
|
return true
|
|
946
1191
|
}
|
|
947
1192
|
|
|
948
1193
|
/**
|
|
949
|
-
* グレースフル切断(Stage 5)。**すぐには reap しない**。
|
|
950
|
-
*
|
|
951
|
-
*
|
|
1194
|
+
* グレースフル切断(Stage 5 + 案B T06542)。**すぐには reap しない**。
|
|
1195
|
+
*
|
|
1196
|
+
* 案B: detach は「この端末分の購読解除」。まだ他端末が購読していればセッションは生かし、
|
|
1197
|
+
* detachRequested も立てない(別端末が見ているため warm ではなく現役)。最後の 1 台が外れた
|
|
1198
|
+
* ときだけ detachRequested を立て warm 保持し、以降は周期 reconcile(LRU 上限 / 長 idle TTL /
|
|
1199
|
+
* stuck)に委ねる。これにより「別 CLI 画面へ切替えた瞬間 5 分で殺される」問題を解消しつつ、
|
|
1200
|
+
* 多端末購読中に 1 タブ閉じても残りの端末のライブ表示が切れない。
|
|
952
1201
|
*/
|
|
953
1202
|
detach({ stream_id }) {
|
|
954
1203
|
const session = this.sessions.get(stream_id)
|
|
955
1204
|
if (!session || session.dead) return
|
|
1205
|
+
// ⚠️ 不変条件(案B): **live(非 dead)session は常に stream key を 1 つ以上持つ**。codex は
|
|
1206
|
+
// claude と違い warm セッションの唯一の索引が this.sessions なので、最後の 1 端末分の key まで
|
|
1207
|
+
// 消すと reconcile が到達できず(reap されずリーク)、_findLiveSessionByCwd の reattach 引き当ても
|
|
1208
|
+
// 効かなくなる。よって「購読が 2 つ以上のときだけ実際に外す」。
|
|
1209
|
+
if (session.streamIds.size > 1) {
|
|
1210
|
+
// 他端末がまだ購読中: この端末分の購読解除のみ。セッションは残りの端末が生かす。
|
|
1211
|
+
session.streamIds.delete(stream_id)
|
|
1212
|
+
session.lastActivityByStream.delete(stream_id)
|
|
1213
|
+
if (this.sessions.get(stream_id) === session) this.sessions.delete(stream_id)
|
|
1214
|
+
// primary を生存端末へ寄せ替える(末尾=最も新しく購読した端末を .at(-1) で明示選択)。
|
|
1215
|
+
if (session.stream_id === stream_id) {
|
|
1216
|
+
session.stream_id = Array.from(session.streamIds).at(-1)
|
|
1217
|
+
}
|
|
1218
|
+
this.logger?.info?.(
|
|
1219
|
+
{
|
|
1220
|
+
stream_id,
|
|
1221
|
+
thread_id: session.threadId,
|
|
1222
|
+
cwd: session.cwd,
|
|
1223
|
+
remaining_subscribers: session.streamIds.size,
|
|
1224
|
+
},
|
|
1225
|
+
"codex terminal unsubscribed (session kept; other terminals still watching)",
|
|
1226
|
+
)
|
|
1227
|
+
return
|
|
1228
|
+
}
|
|
1229
|
+
// 最後の 1 台が外れた: key / streamIds は残したまま(reconcile 到達性のため)warm 保持し、
|
|
1230
|
+
// detachRequested を立てる。以降は周期 reconcile(LRU 上限 / 長 idle TTL / stuck)が reap を判断する。
|
|
956
1231
|
session.detachRequested = true
|
|
957
1232
|
this.logger?.info?.(
|
|
958
1233
|
{
|
|
@@ -961,10 +1236,55 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
961
1236
|
cwd: session.cwd,
|
|
962
1237
|
busy: !!session.activeTurnId,
|
|
963
1238
|
},
|
|
964
|
-
"codex detach requested by browser (kept warm; reconcile handles reap)",
|
|
1239
|
+
"codex detach requested by browser (last terminal; kept warm; reconcile handles reap)",
|
|
965
1240
|
)
|
|
966
1241
|
}
|
|
967
1242
|
|
|
1243
|
+
/**
|
|
1244
|
+
* 案B (T06542): 死端末 GC。多端末購読で、クラッシュ / タブ強制終了で codex.detach を送れずに
|
|
1245
|
+
* 消えた購読端末(一定期間 input/permission/interrupt/keepalive の活性が無い stream_id)を購読集合
|
|
1246
|
+
* から掃除する。claude-stream-bridge.mjs の gcDeadTerminals と同型。複数端末が購読するセッション
|
|
1247
|
+
* のみ対象(1 台しか居なければ reconcile の idle TTL / LRU に任せる)。全端末が死端末で空になったら
|
|
1248
|
+
* detachRequested を立て warm 保持へ落とす。reconcileSessions の先頭から呼ばれる(main.mjs の
|
|
1249
|
+
* state loop 経由)ほか、テストから直接も呼べる。
|
|
1250
|
+
* @param {number} [ttlMs] 死端末とみなす無活性閾値(テスト用に上書き可)。
|
|
1251
|
+
* @returns {number} GC した端末キー数。
|
|
1252
|
+
*/
|
|
1253
|
+
gcDeadTerminals(ttlMs = this._deadTerminalTtlMs) {
|
|
1254
|
+
const now = Date.now()
|
|
1255
|
+
let removed = 0
|
|
1256
|
+
for (const session of this._liveSessions()) {
|
|
1257
|
+
// 複数端末が購読しているセッションのみ対象(1 台なら reconcile の idle TTL / LRU に任せる)。
|
|
1258
|
+
if (session.streamIds.size <= 1) continue
|
|
1259
|
+
for (const sid of Array.from(session.streamIds)) {
|
|
1260
|
+
// ⚠️ 不変条件: live session は常に stream key を 1 つ以上残す(reconcile 到達性)。全端末が
|
|
1261
|
+
// 死端末でも最後の 1 個は消さず、下で detachRequested を立てて idle TTL reap に委ねる。
|
|
1262
|
+
if (session.streamIds.size <= 1) break
|
|
1263
|
+
const last = session.lastActivityByStream.get(sid) ?? 0
|
|
1264
|
+
if (now - last < ttlMs) continue
|
|
1265
|
+
session.streamIds.delete(sid)
|
|
1266
|
+
session.lastActivityByStream.delete(sid)
|
|
1267
|
+
if (this.sessions.get(sid) === session) this.sessions.delete(sid)
|
|
1268
|
+
removed += 1
|
|
1269
|
+
if (session.stream_id === sid) {
|
|
1270
|
+
session.stream_id = Array.from(session.streamIds).at(-1)
|
|
1271
|
+
}
|
|
1272
|
+
this.logger?.info?.(
|
|
1273
|
+
{ stream_id: sid, thread_id: session.threadId, cwd: session.cwd },
|
|
1274
|
+
"codex dead terminal GC'd (no activity past TTL)",
|
|
1275
|
+
)
|
|
1276
|
+
}
|
|
1277
|
+
// 生き残った最後の 1 端末も死端末(無活性が TTL 超過)なら warm 保持へ落とす
|
|
1278
|
+
// (key は残したまま。reconcile が idle TTL / LRU で最終的に reap する)。
|
|
1279
|
+
if (session.streamIds.size === 1) {
|
|
1280
|
+
const [onlySid] = session.streamIds
|
|
1281
|
+
const last = session.lastActivityByStream.get(onlySid) ?? 0
|
|
1282
|
+
if (now - last >= ttlMs) session.detachRequested = true
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
return removed
|
|
1286
|
+
}
|
|
1287
|
+
|
|
968
1288
|
/**
|
|
969
1289
|
* 診断用: 生きているセッションの一覧 (sibling-inject の失敗理由に載せる / ログ用)。
|
|
970
1290
|
* 「TUI codex は hub-agent の管理外なので ask-sibling の宛先にならない」ことを
|
|
@@ -978,8 +1298,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
978
1298
|
describeLiveSessions(cwd) {
|
|
979
1299
|
const now = Date.now()
|
|
980
1300
|
const out = []
|
|
981
|
-
|
|
982
|
-
|
|
1301
|
+
// 案B: sessions は多対一なので distinct session で走査する(購読端末数ぶん重複させない)。
|
|
1302
|
+
for (const session of this._liveSessions()) {
|
|
983
1303
|
if (cwd && session.cwd !== cwd) continue
|
|
984
1304
|
out.push({
|
|
985
1305
|
kind: "codex",
|
|
@@ -1034,8 +1354,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1034
1354
|
if (!cwd || !text) return false
|
|
1035
1355
|
let best = null
|
|
1036
1356
|
let bestAt = -1
|
|
1037
|
-
|
|
1038
|
-
|
|
1357
|
+
// 案B: distinct session で走査(多対一 Map の重複を除く)。
|
|
1358
|
+
for (const session of this._liveSessions()) {
|
|
1359
|
+
if (session.cwd !== cwd) continue
|
|
1039
1360
|
// 宛先選定の「最新」は会話ターン境界時刻で判定する(閲覧 keepalive で動く
|
|
1040
1361
|
// lastActivityMs を使うと「見ているだけの古い会話」が本命を追い越す)。
|
|
1041
1362
|
const at = session.lastTurnAtMs ?? session.lastActivityMs ?? 0
|
|
@@ -1109,9 +1430,11 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1109
1430
|
if (!cwd || !id || !text) return { status: "not_found" }
|
|
1110
1431
|
let bySid = null
|
|
1111
1432
|
const byThread = []
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1433
|
+
// 案B: distinct session で走査。id は **任意の購読 stream_id**(primary とは限らない)に
|
|
1434
|
+
// 一致し得るので streamIds.has(id) で判定する(多端末購読の非 primary 端末も宛先にできる)。
|
|
1435
|
+
for (const session of this._liveSessions()) {
|
|
1436
|
+
if (session.cwd !== cwd) continue
|
|
1437
|
+
if (session.streamIds.has(id)) {
|
|
1115
1438
|
bySid = session
|
|
1116
1439
|
break // stream_id は一意なので即確定
|
|
1117
1440
|
}
|
|
@@ -1152,15 +1475,26 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1152
1475
|
}
|
|
1153
1476
|
|
|
1154
1477
|
/**
|
|
1155
|
-
* Stage 5: セッションが「保護対象(reap 不可)」か。= **healthy な running turn
|
|
1156
|
-
*
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
1478
|
+
* Stage 5: セッションが「保護対象(reap 不可)」か。= **healthy な running turn**、
|
|
1479
|
+
* または **in-flight な入力処理中**(codex-review round5 Finding 2 対応)。
|
|
1480
|
+
* - in-flight: 単一 pump が走行中(`pumping`)/未処理 FIFO が残っている(`inputQueue`)/
|
|
1481
|
+
* turn/start RPC 送信後に turn/started を待っている(`_turnActiveWaiter`)状態。この間は
|
|
1482
|
+
* `activeTurnId` が未確定(null)でもターンが実際に始まりかけているので、per-cwd / N-cwd /
|
|
1483
|
+
* idle-TTL いずれでも reap してはいけない(別 attach が Phase 1 を発火した瞬間に処理中
|
|
1484
|
+
* セッションを殺す事故を防ぐ)。pump は turn/start timeout で必ず有限時間に畳まれるため、
|
|
1485
|
+
* ここで保護してもハング検出(stuck-turn)が効かなくなることはない。
|
|
1486
|
+
* - healthy running turn: `activeTurnId` が立ち、app-server 通知が STUCK_TTL 以内に届いている。
|
|
1487
|
+
* activeTurnId 固着で通知が STUCK_TTL 途絶えたもの(stuck/ハング)は protected から外し reap
|
|
1488
|
+
* 対象にする(プロセスリーク・固着の救済)。
|
|
1159
1489
|
* @param {object} session
|
|
1160
1490
|
* @param {number} now
|
|
1161
1491
|
* @returns {boolean}
|
|
1162
1492
|
*/
|
|
1163
1493
|
_isProtected(session, now) {
|
|
1494
|
+
// in-flight 入力処理中(turn/start RPC 応答待ち等、activeTurnId 未確定でも処理中)を保護。
|
|
1495
|
+
if (session.pumping) return true
|
|
1496
|
+
if (Array.isArray(session.inputQueue) && session.inputQueue.length > 0) return true
|
|
1497
|
+
if (session._turnActiveWaiter) return true
|
|
1164
1498
|
if (!session.activeTurnId) return false
|
|
1165
1499
|
return now - (session.lastActivityMs ?? now) <= this._stuckTurnTtlMs
|
|
1166
1500
|
}
|
|
@@ -1169,7 +1503,11 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1169
1503
|
_reapSession(session, now, reason) {
|
|
1170
1504
|
if (session.dead) return
|
|
1171
1505
|
session.dead = true
|
|
1172
|
-
|
|
1506
|
+
// round3: reap した session の barrier を解除し queue を reject する(promise 宙吊り防止)。
|
|
1507
|
+
this._settleTurnActiveWaiter(session)
|
|
1508
|
+
this._drainRejectQueue(session, { reason: "queue_dead" })
|
|
1509
|
+
// 案B: 全購読 stream_id キーを Map から外す(primary 1 個だけ消すと dangling が残る)。
|
|
1510
|
+
this._dropAllStreamKeys(session)
|
|
1173
1511
|
try {
|
|
1174
1512
|
session.client.stop()
|
|
1175
1513
|
} catch (err) {
|
|
@@ -1182,7 +1520,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1182
1520
|
cwd: session.cwd,
|
|
1183
1521
|
reason,
|
|
1184
1522
|
idle_ms: now - (session.lastActivityMs ?? now),
|
|
1185
|
-
live_after: this.
|
|
1523
|
+
live_after: this._liveSessions().length,
|
|
1186
1524
|
},
|
|
1187
1525
|
"codex session reaped",
|
|
1188
1526
|
)
|
|
@@ -1195,25 +1533,74 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1195
1533
|
}
|
|
1196
1534
|
|
|
1197
1535
|
/**
|
|
1198
|
-
*
|
|
1199
|
-
*
|
|
1200
|
-
*
|
|
1201
|
-
*
|
|
1536
|
+
* 子⑤ (T06544): warm 保持を「各 cwd 1 本 × 同時 warm 保持する cwd 数 N(既定 20)」で有界化する。
|
|
1537
|
+
* 旧 `_evictWarmPoolOverflow`(マシン全体の総セッション数 LRU=5)を置換し、境界を「総数」ではなく
|
|
1538
|
+
* 「cwd 単位」で定義する(親 T06539「1 cwd 1 本化」に整合)。
|
|
1539
|
+
* Phase 1 (per-cwd=1): 同一 cwd に 2 本以上の live session があれば、最終活性が最新の 1 本を残し
|
|
1540
|
+
* 残りの **非 protected** を reap する(WS 再接続や別スレッド開始で一時的に増えた重複を畳む)。
|
|
1541
|
+
* Phase 2 (N-cwd LRU): live session を持つ distinct cwd 数が warmCwdMax を超えたら、cwd の最終活性
|
|
1542
|
+
* が最も古い cwd から、その cwd の非 protected session を reap して cwd 数を上限まで戻す。
|
|
1543
|
+
* protected(healthy running turn / stuck 未満)は Phase 1/2 いずれでも reap しない(busy 不可侵)。
|
|
1544
|
+
* 全 cwd が busy 等で上限に収められないときは一時的に超過を許容する(走行中ターンを絶対に殺さない)。
|
|
1545
|
+
* 多端末購読は 1 session に集約されるため(_liveSessions で dedup)、purely な多端末利用では
|
|
1546
|
+
* per-cwd=1 は自動的に満たされ、Phase 1 は「同一 cwd に別 app-server が 2 本以上できたとき」だけ発火する。
|
|
1547
|
+
* genuine attach 直後と reconcile の両方から呼ぶ。
|
|
1202
1548
|
* @param {number} now
|
|
1203
|
-
* @returns {number}
|
|
1549
|
+
* @returns {number} reap した数
|
|
1204
1550
|
*/
|
|
1205
|
-
|
|
1206
|
-
const live = Array.from(this.sessions.values()).filter((s) => !s.dead)
|
|
1207
|
-
const overflow = live.length - this._warmPoolMax
|
|
1208
|
-
if (overflow <= 0) return 0
|
|
1209
|
-
const evictable = live
|
|
1210
|
-
.filter((s) => !this._isProtected(s, now))
|
|
1211
|
-
.sort((a, b) => (a.lastActivityMs ?? 0) - (b.lastActivityMs ?? 0)) // 古い順
|
|
1551
|
+
_reconcileWarmBounds(now, reason = "warm-bounds") {
|
|
1212
1552
|
let evicted = 0
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1553
|
+
// Phase 1: per-cwd=1。cwd ごとに最終活性が新しい順へ並べ、先頭(最新)以外の非 protected を reap。
|
|
1554
|
+
const byCwd = new Map()
|
|
1555
|
+
for (const s of this._liveSessions()) {
|
|
1556
|
+
const list = byCwd.get(s.cwd)
|
|
1557
|
+
if (list) list.push(s)
|
|
1558
|
+
else byCwd.set(s.cwd, [s])
|
|
1559
|
+
}
|
|
1560
|
+
for (const list of byCwd.values()) {
|
|
1561
|
+
if (list.length <= 1) continue
|
|
1562
|
+
// 新しい順。recency 同値は seq(生成順)で必ず新しい方を先頭(= 残す)にする(round5 Finding 1)。
|
|
1563
|
+
list.sort(
|
|
1564
|
+
(a, b) =>
|
|
1565
|
+
(b.lastActivityMs ?? 0) - (a.lastActivityMs ?? 0) || (b.seq ?? 0) - (a.seq ?? 0),
|
|
1566
|
+
)
|
|
1567
|
+
for (let i = 1; i < list.length; i += 1) {
|
|
1568
|
+
if (this._isProtected(list[i], now)) continue // busy は残す(一時的に >1 を許容)
|
|
1569
|
+
this._reapSession(list[i], now, `${reason}:per-cwd`)
|
|
1570
|
+
evicted += 1
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
// Phase 2: N-cwd LRU。per-cwd 畳み込み後の live から cwd 単位の recency と protected 有無を集計。
|
|
1574
|
+
const cwdInfo = new Map() // cwd -> { recency, seq, hasProtected, sessions: [] }
|
|
1575
|
+
for (const s of this._liveSessions()) {
|
|
1576
|
+
let info = cwdInfo.get(s.cwd)
|
|
1577
|
+
if (!info) {
|
|
1578
|
+
info = { recency: -Infinity, seq: -Infinity, hasProtected: false, sessions: [] }
|
|
1579
|
+
cwdInfo.set(s.cwd, info)
|
|
1580
|
+
}
|
|
1581
|
+
info.recency = Math.max(info.recency, s.lastActivityMs ?? 0)
|
|
1582
|
+
info.seq = Math.max(info.seq, s.seq ?? 0) // round5 Finding 1: recency 同値の cwd タイブレーク用
|
|
1583
|
+
if (this._isProtected(s, now)) info.hasProtected = true
|
|
1584
|
+
info.sessions.push(s)
|
|
1585
|
+
}
|
|
1586
|
+
const overflow = cwdInfo.size - this._warmCwdMax
|
|
1587
|
+
if (overflow > 0) {
|
|
1588
|
+
// busy を含む cwd は退避対象外(上限にカウントはするが reap しない)。最古 recency の cwd から。
|
|
1589
|
+
// recency 同値は seq(新しさ)で必ず新しい cwd を残す(古い方から evict)。
|
|
1590
|
+
const evictableCwds = Array.from(cwdInfo.values())
|
|
1591
|
+
.filter((info) => !info.hasProtected)
|
|
1592
|
+
.sort((a, b) => a.recency - b.recency || a.seq - b.seq) // 古い順
|
|
1593
|
+
let cwdsEvicted = 0
|
|
1594
|
+
for (const info of evictableCwds) {
|
|
1595
|
+
if (cwdsEvicted >= overflow) break
|
|
1596
|
+
for (const s of info.sessions) {
|
|
1597
|
+
// per-cwd 畳み込み後は各 cwd 1 本のはずだが、念のため非 protected のみ reap。
|
|
1598
|
+
if (this._isProtected(s, now)) continue
|
|
1599
|
+
this._reapSession(s, now, `${reason}:cwd-lru`)
|
|
1600
|
+
evicted += 1
|
|
1601
|
+
}
|
|
1602
|
+
cwdsEvicted += 1
|
|
1603
|
+
}
|
|
1217
1604
|
}
|
|
1218
1605
|
return evicted
|
|
1219
1606
|
}
|
|
@@ -1231,8 +1618,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1231
1618
|
* 1. **stuck turn**: activeTurnId 固着だが app-server 通知が STUCK_TTL 途絶えた(ハング)→ reap
|
|
1232
1619
|
* (healthy な長ターンは reasoning/output delta で touch されるため該当しない)。
|
|
1233
1620
|
* 2. **long idle**: 非 busy が idle TTL(既定 7 日)無活性 → reap(warm 保持の期限切れ)。
|
|
1234
|
-
* 3. **
|
|
1235
|
-
*
|
|
1621
|
+
* 3. **warm 境界**(子⑤ T06544): `_reconcileWarmBounds` で「各 cwd 1 本 × 同時 warm cwd 数 N
|
|
1622
|
+
* (既定 20)」に有界化。同一 cwd の重複は最新 1 本へ畳み、cwd 数超過は最古 cwd から reap。
|
|
1236
1623
|
* busy(healthy running turn)は 1〜3 いずれの対象にもならない(走行中ターンを絶対に殺さない)。
|
|
1237
1624
|
*
|
|
1238
1625
|
* 旧 `gcAbandonedSessions`(1h 無活性一律 reap)を置換。detach 済み warm セッションを 1h で
|
|
@@ -1244,9 +1631,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1244
1631
|
reconcileSessions() {
|
|
1245
1632
|
const now = Date.now()
|
|
1246
1633
|
let reaped = 0
|
|
1634
|
+
// 案B: 0. 死端末 GC を先に回す(多端末購読で応答が途絶えた端末を購読集合から掃除)。これで
|
|
1635
|
+
// 「全端末が死んで空になったセッション」が下の idle TTL 経路で正しく reap 対象になる。
|
|
1636
|
+
this.gcDeadTerminals()
|
|
1247
1637
|
// 1〜2: stuck turn / long idle。protected(healthy running turn)はスキップ。
|
|
1248
|
-
|
|
1249
|
-
|
|
1638
|
+
// 案B: distinct session で走査(多対一 Map の重複 reap を避ける)。
|
|
1639
|
+
for (const session of this._liveSessions()) {
|
|
1250
1640
|
if (this._isProtected(session, now)) continue
|
|
1251
1641
|
const idle = now - (session.lastActivityMs ?? now)
|
|
1252
1642
|
if (session.activeTurnId && idle > this._stuckTurnTtlMs) {
|
|
@@ -1257,8 +1647,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1257
1647
|
reaped += 1
|
|
1258
1648
|
}
|
|
1259
1649
|
}
|
|
1260
|
-
// 3: LRU
|
|
1261
|
-
reaped += this.
|
|
1650
|
+
// 3: warm 境界(per-cwd=1 / N-cwd LRU)を適用(stuck/idle reap 後の生存数で判定)。
|
|
1651
|
+
reaped += this._reconcileWarmBounds(now, "warm-bounds")
|
|
1262
1652
|
return reaped
|
|
1263
1653
|
}
|
|
1264
1654
|
|
|
@@ -1269,8 +1659,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1269
1659
|
|
|
1270
1660
|
/** 全セッションを即座に終了する (agent shutdown 用。ターン完走は待たない)。 */
|
|
1271
1661
|
shutdown() {
|
|
1272
|
-
|
|
1662
|
+
// 案B: distinct session で走査(多対一 Map の重複 stop を避ける)。
|
|
1663
|
+
for (const session of this._liveSessions()) {
|
|
1273
1664
|
session.dead = true
|
|
1665
|
+
// round3: shutdown で barrier を解除し queue 入力を reject(promise 宙吊り防止)。
|
|
1666
|
+
this._settleTurnActiveWaiter(session)
|
|
1667
|
+
this._drainRejectQueue(session, { reason: "queue_dead" })
|
|
1274
1668
|
// hub-agent 再起動時にターン実行中のセッションを落とすと thread が汚染されうる
|
|
1275
1669
|
// (exec 中の kill → output 欠落)。因果を後から辿れるよう必ず記録する。
|
|
1276
1670
|
this.logger?.[session.activeTurnId ? "warn" : "info"]?.(
|