@cocorograph/hub-agent 0.7.79 → 0.7.80
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
|
@@ -122,20 +122,20 @@ function readNeedsAuthCacheNames() {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
/** 多端末共有セッション (2026-05-30)。同じ Claude セッション
|
|
126
|
-
*
|
|
127
|
-
* HUB_AGENT_CHAT_SHARED="1" で有効化 (デフォルト無効 = 従来の「最後の端末だけが live」)。
|
|
125
|
+
/** 多端末共有セッション (2026-05-30、T06542 で既定 ON 化・フラグ撤去)。同じ Claude セッション
|
|
126
|
+
* (session_id) を複数端末で同時に開いて、どの端末でもライブ表示・入力・許可応答できる。
|
|
128
127
|
*
|
|
129
|
-
*
|
|
130
|
-
* - reattach で旧 stream_id を sessions Map から消さず、1 セッションに複数の端末
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* - detach は購読端末の参照カウントを下げ、最後の 1 台が外れて初めて idle softDetach
|
|
134
|
-
*
|
|
135
|
-
* 配信のファンアウト自体は Hub backend が session_id 単位の group broadcast で行うため、
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
|
|
128
|
+
* 挙動 (常時):
|
|
129
|
+
* - reattach で旧 stream_id を sessions Map から消さず、1 セッションに複数の端末 stream_id を
|
|
130
|
+
* 購読登録する (this.streamIds)。これにより旧端末からの input / permission.reply / interrupt
|
|
131
|
+
* も sessions.get(stream_id) で解決できる。
|
|
132
|
+
* - detach は購読端末の参照カウントを下げ、最後の 1 台が外れて初めて idle softDetach する
|
|
133
|
+
* (1 台閉じただけで走行中セッションを撤去しない)。
|
|
134
|
+
* 配信のファンアウト自体は Hub backend が session_id 単位の group broadcast で行うため、agent 側は
|
|
135
|
+
* イベントに session_id を載せるだけでよい (既に emit 済み)。
|
|
136
|
+
*
|
|
137
|
+
* ⚠️ 旧 env `HUB_AGENT_CHAT_SHARED` は撤去した (T06542)。以前フラグ OFF だと「最後の端末だけ
|
|
138
|
+
* live」に退行し、環境間で挙動が割れる事故源だったため、単一挙動へ一本化した (Codex 側 案B と対称)。 */
|
|
139
139
|
|
|
140
140
|
/** B11: 多端末共有時、一定期間 input/permission 等の活性が無い購読端末キーを死端末とみなして
|
|
141
141
|
* GC する閾値 (ミリ秒)。端末がクラッシュして claude.detach を送らず消えると streamIds /
|
|
@@ -322,15 +322,15 @@ class ClaudeStreamSession {
|
|
|
322
322
|
onTurnSettled,
|
|
323
323
|
}) {
|
|
324
324
|
this.stream_id = stream_id
|
|
325
|
-
/**
|
|
326
|
-
*
|
|
325
|
+
/** 多端末共有 (T06542 で常時): このセッションを購読中の全端末 stream_id。reattach で増え
|
|
326
|
+
* detach で減る。最後の 1 台が外れると idle softDetach に入る。
|
|
327
327
|
* this.stream_id は「最も新しく attach した端末」= legacy emit / primary 用に残す。 */
|
|
328
328
|
this.streamIds = new Set([stream_id])
|
|
329
329
|
/** B11: 購読端末ごとの最終アクティビティ時刻 (epoch ms)。attach / reattach / input /
|
|
330
|
-
* permission
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
330
|
+
* permission 応答で更新する。「死端末 GC」(gcDeadTerminals) が参照し、一定期間 無活動の
|
|
331
|
+
* 購読端末キーを掃除する。これが無いと端末がクラッシュして claude.detach を送らず消えると
|
|
332
|
+
* stream_id が streamIds/sessions に永久残留し、最後の 1 台が外れないと idle softDetach が
|
|
333
|
+
* 起動しないため死端末がセッションを永久に生かしてしまう。 */
|
|
334
334
|
this.lastActivityByStream = new Map([[stream_id, Date.now()]])
|
|
335
335
|
this.cwd = cwd
|
|
336
336
|
this.model = model || null
|
|
@@ -1866,12 +1866,9 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1866
1866
|
// ユニークだが異常系でも cwd を跨がない)。
|
|
1867
1867
|
const live = this._liveBySession.get(this._liveKeyFor(scwd, resumeSessionId))
|
|
1868
1868
|
if (live && !live._closed) {
|
|
1869
|
-
// 多端末共有 (
|
|
1870
|
-
//
|
|
1871
|
-
//
|
|
1872
|
-
if (!CHAT_SHARED_ENABLED) {
|
|
1873
|
-
this.sessions.delete(live.stream_id)
|
|
1874
|
-
}
|
|
1869
|
+
// 多端末共有 (T06542 で常時): 旧 stream_id を Map から消さない。同一セッションを複数端末が
|
|
1870
|
+
// 購読する状態を作り、どの端末の input/permission/interrupt も解決できるようにする
|
|
1871
|
+
// (旧端末は reattach.streamIds に足され、backend が session group へ fan-out する)。
|
|
1875
1872
|
// 改修5: 再アタッチ時に model/permission/maxThinkingTokens を引き継ぎ反映する。
|
|
1876
1873
|
// browser はバッジ変更時に新しい値を載せた claude.attach を同一 resume で送る
|
|
1877
1874
|
// ため、ここで適用すると常駐 query を維持したまま次ターンから切り替わる。
|
|
@@ -2052,6 +2049,11 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2052
2049
|
this.logger?.warn({ stream_id, request_id }, "claude.permission.reply but stream missing")
|
|
2053
2050
|
return false
|
|
2054
2051
|
}
|
|
2052
|
+
// T06542 バグ修正: 許可応答も端末の活性シグナル。touch を呼ばないと「入力せず許可応答だけを
|
|
2053
|
+
// 返し続ける端末」が lastActivityByStream 未更新のまま gcDeadTerminals(1h) に死端末として掃除され、
|
|
2054
|
+
// 以降その端末からの入力/応答が stream_missing になる (session 定義コメント「permission 応答で
|
|
2055
|
+
// 更新する」と実装が不一致だった)。多端末共有を常時 ON 化すると顕在化するためここで直す。
|
|
2056
|
+
s.touch(stream_id)
|
|
2055
2057
|
const decision = allow
|
|
2056
2058
|
? { behavior: "allow", updatedInput: updatedInput || {} }
|
|
2057
2059
|
: { behavior: "deny", message: denyMessage || "denied by user" }
|
|
@@ -2550,21 +2552,19 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2550
2552
|
detach({ stream_id }) {
|
|
2551
2553
|
const s = this.sessions.get(stream_id)
|
|
2552
2554
|
if (!s) return false
|
|
2553
|
-
//
|
|
2554
|
-
// softDetach (idle 撤去タイマー) には入らない。最後の 1 台が外れたときだけ従来の
|
|
2555
|
-
// idle softDetach
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
s.stream_id = Array.from(s.streamIds).at(-1)
|
|
2565
|
-
}
|
|
2566
|
-
return true
|
|
2555
|
+
// 多端末共有 (T06542 で常時): この端末分の購読だけ外す。まだ他端末が見ているならセッションは
|
|
2556
|
+
// 生かし、softDetach (idle 撤去タイマー) には入らない。最後の 1 台が外れたときだけ従来の
|
|
2557
|
+
// idle softDetach に進む。
|
|
2558
|
+
s.streamIds.delete(stream_id)
|
|
2559
|
+
s.lastActivityByStream.delete(stream_id) // B11: 活性記録も掃除
|
|
2560
|
+
if (this.sessions.get(stream_id) === s) this.sessions.delete(stream_id)
|
|
2561
|
+
if (s.streamIds.size > 0) {
|
|
2562
|
+
// primary を生存端末へ寄せ替え (legacy emit / softDetach 撤去ログ用)。
|
|
2563
|
+
// 末尾 (最も新しく購読した端末) を .at(-1) で明示的に選ぶ。
|
|
2564
|
+
if (s.stream_id === stream_id) {
|
|
2565
|
+
s.stream_id = Array.from(s.streamIds).at(-1)
|
|
2567
2566
|
}
|
|
2567
|
+
return true
|
|
2568
2568
|
}
|
|
2569
2569
|
// 常駐化: browser 切断ではセッションを止めない (reap しない)。走行中ターンは完走し、
|
|
2570
2570
|
// idle のまま IDLE_DETACH_TTL_MS 経過して初めて撤去する。TTL 内に再接続 (再アタッチ)
|
|
@@ -2585,11 +2585,10 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2585
2585
|
/** B11: 多端末共有時に「死端末」(一定期間 input/permission 等の活性が無い購読端末) を
|
|
2586
2586
|
* GC する。端末がクラッシュして claude.detach を送らず消えた stream_id を掃除し、最後の
|
|
2587
2587
|
* 1 台が外れたら通常の idle softDetach (走行中は完走を待つ) に進ませる。state loop など
|
|
2588
|
-
*
|
|
2588
|
+
* 既存の定期ループから呼ぶ想定 (T06542 で常時有効)。
|
|
2589
2589
|
* @param {number} [ttlMs] 死端末とみなす無活性閾値 (テスト用に上書き可)。
|
|
2590
2590
|
* @returns {number} GC した端末キー数。 */
|
|
2591
2591
|
gcDeadTerminals(ttlMs = DEAD_TERMINAL_TTL_MS) {
|
|
2592
|
-
if (!CHAT_SHARED_ENABLED) return 0
|
|
2593
2592
|
const now = Date.now()
|
|
2594
2593
|
let removed = 0
|
|
2595
2594
|
for (const session of new Set(this.sessions.values())) {
|
|
@@ -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 をそのまま素通しするので呼び出し側が組み立てる)。
|
|
@@ -98,6 +102,21 @@ const STUCK_TURN_TTL_MS =
|
|
|
98
102
|
Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) ||
|
|
99
103
|
60 * 60 * 1000
|
|
100
104
|
|
|
105
|
+
// 案B (T06542): 死端末 GC の TTL。多端末購読で、応答を返さなくなった(クラッシュ / タブ強制終了で
|
|
106
|
+
// codex.detach を送れなかった)購読端末を購読集合から掃除する閾値。claude-stream-bridge.mjs の
|
|
107
|
+
// DEAD_TERMINAL_TTL_MS(既定 1h)と同型・同既定。stuck turn TTL とは別軸(あちらはターン固着、
|
|
108
|
+
// こちらは端末の無応答)だが既定は揃える。
|
|
109
|
+
const DEAD_TERMINAL_TTL_MS =
|
|
110
|
+
Number(process.env.HUB_AGENT_CODEX_DEAD_TERMINAL_TTL_MS) ||
|
|
111
|
+
Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) ||
|
|
112
|
+
60 * 60 * 1000
|
|
113
|
+
|
|
114
|
+
// 案B (T06542, codex-review round3 major#2): turn/start RPC 成功後に turn/started 通知が来るのを待つ
|
|
115
|
+
// 上限。これを超えたら「ターンが正しく始まらなかった」とみなし、pump が待機中の queue を reject して
|
|
116
|
+
// 状態を解除する(turn/started/completed/exit が来ないケースで promise が永久宙吊りになるのを防ぐ)。
|
|
117
|
+
const TURN_START_TIMEOUT_MS =
|
|
118
|
+
Number(process.env.HUB_AGENT_CODEX_TURN_START_TIMEOUT_MS) || 30 * 1000
|
|
119
|
+
|
|
101
120
|
const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
|
|
102
121
|
|
|
103
122
|
// T06499 (2026-07-18): 巨大スレッドの resume ガード。長期運用したスレッドの rollout は
|
|
@@ -273,6 +292,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
273
292
|
detachTtlMs,
|
|
274
293
|
warmPoolMax,
|
|
275
294
|
stuckTurnTtlMs,
|
|
295
|
+
deadTerminalTtlMs,
|
|
296
|
+
turnStartTimeoutMs,
|
|
276
297
|
rolloutPresence,
|
|
277
298
|
historyMaxItems,
|
|
278
299
|
historyMaxBytes,
|
|
@@ -287,6 +308,10 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
287
308
|
this._warmPoolMax = warmPoolMax ?? WARM_POOL_MAX
|
|
288
309
|
// Stage 5: stuck turn 検出 TTL(既定 1h)。テストから短縮可能。
|
|
289
310
|
this._stuckTurnTtlMs = stuckTurnTtlMs ?? STUCK_TURN_TTL_MS
|
|
311
|
+
// 案B (T06542): 死端末 GC の TTL(既定 1h)。テストから短縮可能。
|
|
312
|
+
this._deadTerminalTtlMs = deadTerminalTtlMs ?? DEAD_TERMINAL_TTL_MS
|
|
313
|
+
// round3 major#2: turn/start 後に turn/started を待つ上限(既定 30s)。テストから短縮可能。
|
|
314
|
+
this._turnStartTimeoutMs = turnStartTimeoutMs ?? TURN_START_TIMEOUT_MS
|
|
290
315
|
// #4 失敗分類の rollout 存在チェック (tri-state。テストから注入可能)。
|
|
291
316
|
this._rolloutPresence = rolloutPresence || codexRolloutPresence
|
|
292
317
|
// T06499: 履歴 hydrate の上限 (テストから小さい値を注入可能)。
|
|
@@ -381,8 +406,19 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
381
406
|
// unhandled rejection を作らず、複数の reconnect が同じ promise を安全に await できる)。
|
|
382
407
|
let settleReady
|
|
383
408
|
const session = {
|
|
409
|
+
// primary stream_id: legacy な単一値 emit / ログ用。案B では最新購読端末へ寄せる
|
|
410
|
+
// (streamIds が真の購読者集合)。app-server 通知や emit は都度 session.stream_id を読む。
|
|
384
411
|
stream_id,
|
|
385
412
|
cwd,
|
|
413
|
+
// 案B (T06542): 多端末購読集合。この live session を購読中の全端末 stream_id。reattach は
|
|
414
|
+
// 「所有権付け替え」ではなく「購読者追加」。detach で当該端末を外し、最後の1台が外れたら
|
|
415
|
+
// warm 保持(reconcile が LRU/idle TTL/stuck で reap)。sessions Map は各購読 stream_id →
|
|
416
|
+
// この session の多対一。distinct session 走査は必ず _liveSessions() で dedup する。
|
|
417
|
+
streamIds: new Set([stream_id]),
|
|
418
|
+
// 死端末 GC 用の端末別最終活性。browser→agent アクション(input/permission/interrupt/
|
|
419
|
+
// keepalive)で更新し、応答を返さなくなった購読端末を gcDeadTerminals が掃除する
|
|
420
|
+
// (claude-stream-bridge.mjs の lastActivityByStream と同型)。
|
|
421
|
+
lastActivityByStream: new Map([[stream_id, Date.now()]]),
|
|
386
422
|
// #1 in-memory reattach のキー。attach 試行を跨いで安定 (WS 再接続で stream_id は変わる)。
|
|
387
423
|
attachId: attachId || null,
|
|
388
424
|
client,
|
|
@@ -393,6 +429,26 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
393
429
|
// この session を引き当て _tryReattach の session.ready 合流に入り、二重 app-server を作らない。
|
|
394
430
|
resumeThreadId: resumeThreadId || null,
|
|
395
431
|
activeTurnId: null,
|
|
432
|
+
// 案B (T06542, codex-review round3 major#1): 同時入力を **session 単位の単一 FIFO + 単一 pump**で
|
|
433
|
+
// 直列化し、二重 turn/start・queue 追い越し・並行 steer を構造的に排除する。旧 turnStarting/
|
|
434
|
+
// pendingInputs の二重路 (queue 経路 + 直通経路) が overtaking と並行リトライ二重ターンの温床だった。
|
|
435
|
+
/** @type {Array<{input: object[], model?: string, effort?: string, resolve: Function,
|
|
436
|
+
* reject: Function, retriedAsStart?: boolean}>} round3 major#1: **session 単位の単一 FIFO**。
|
|
437
|
+
* 全 input() はここに積まれ、単一 pump (_pumpInputs) が **1 件ずつ直列**に処理する。直通経路を
|
|
438
|
+
* 廃したので、drain 中の新規 input が queue を追い越したり、並行 turn/start・steer を発行して
|
|
439
|
+
* 二重ターンになる事故が起きない。各 promise は **実 delivery (turn/start か turn/steer の RPC)**で
|
|
440
|
+
* settle する (main.mjs が codex.input.ack/nack を実配達に合わせて出せる)。 */
|
|
441
|
+
inputQueue: [],
|
|
442
|
+
/** round3 major#1: pump が走行中か (単一 pump を保証する singleflight フラグ)。 */
|
|
443
|
+
pumping: false,
|
|
444
|
+
/** @type {{resolve: Function, timer: any} | null} round3 major#2: turn/start 後に turn/started
|
|
445
|
+
* (activeTurnId 確定) を待つ barrier の待機者。timeout 付きでハングを防ぐ。 */
|
|
446
|
+
_turnActiveWaiter: null,
|
|
447
|
+
/** round4 major#2: turn/started 通知ごとに +1 する世代カウンタ。pump は turn/start dispatch 前に
|
|
448
|
+
* 値を控え、応答後に「world が前進したか (turn が始まった/更に完了した)」を activeTurnId だけでなく
|
|
449
|
+
* この前進でも判定する。fast-turn (start 応答と started+completed が同一 chunk で pump 再開前に
|
|
450
|
+
* 処理される) で activeTurnId=null に戻っていても、世代前進で検知して誤 timeout を防ぐ。 */
|
|
451
|
+
turnGeneration: 0,
|
|
396
452
|
// request_id -> { turnId, method, params }。major#1: reattach で新 stream へ未応答の承認要求を
|
|
397
453
|
// 再送できるよう、元の method/params も保持する (Claude の _permissionResolvers と同型)。
|
|
398
454
|
pendingApprovals: new Map(),
|
|
@@ -579,32 +635,41 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
579
635
|
* 付け替えられなければ null を返し、呼び出し側 attach() は通常経路へフォールスルーする。
|
|
580
636
|
*/
|
|
581
637
|
async _tryReattach(session, newStreamId) {
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
638
|
+
// 案B (T06542): reattach は「所有権付け替え」ではなく **購読者追加**。旧 stream を Map から
|
|
639
|
+
// 消さず、newStreamId を streamIds へ足して両方が同一 live session を購読する状態を作る
|
|
640
|
+
// (どの端末の input/permission/interrupt も解決でき、backend が thread group へ fan-out する)。
|
|
641
|
+
// これにより「2 本目の app-server を立てず、旧端末も生かしたまま」新端末を相乗りさせる。
|
|
586
642
|
// 戻り値 status:
|
|
587
|
-
// "reattached" -
|
|
588
|
-
// "superseded" -
|
|
589
|
-
//
|
|
643
|
+
// "reattached" - newStreamId を購読者として追加した (info 同梱)。
|
|
644
|
+
// "superseded" - newStreamId が既に別 session を保持している異常時のみ (通常は起きない。
|
|
645
|
+
// attach() が :318 で has(stream_id) を弾くため)。呼び出し側は通常経路に
|
|
646
|
+
// 落とさず失敗させる (二重 thread を作らない)。
|
|
590
647
|
// "unavailable"- session が死んだ / first の thread 確定に失敗 (ライブ session 無し)。
|
|
591
648
|
// 呼び出し側は通常経路へフォールスルーしてよい。
|
|
592
|
-
const originalStreamId = session.stream_id
|
|
593
649
|
let threadId = session.threadId
|
|
594
650
|
if (!threadId && session.ready) threadId = await session.ready
|
|
595
651
|
// first の thread op が失敗 / session が死んだ = ライブ session はもう無い。
|
|
596
652
|
if (!threadId || session.dead) return { status: "unavailable" }
|
|
597
|
-
|
|
598
|
-
if (
|
|
599
|
-
|
|
600
|
-
|
|
653
|
+
const existingForNew = this.sessions.get(newStreamId)
|
|
654
|
+
if (existingForNew && existingForNew !== session) return { status: "superseded" }
|
|
655
|
+
// 購読者追加: streamIds / 端末別活性 / Map を更新し、primary を最新端末へ寄せる
|
|
656
|
+
// (legacy 単一値 emit / ログ用。真の配信は backend の thread group fan-out が担う)。
|
|
657
|
+
const now = Date.now()
|
|
658
|
+
session.streamIds.add(newStreamId)
|
|
659
|
+
session.lastActivityByStream.set(newStreamId, now)
|
|
660
|
+
this.sessions.set(newStreamId, session)
|
|
601
661
|
session.stream_id = newStreamId
|
|
602
662
|
session.detachRequested = false
|
|
603
|
-
session.lastActivityMs =
|
|
604
|
-
this.sessions.set(newStreamId, session)
|
|
663
|
+
session.lastActivityMs = now
|
|
605
664
|
this.logger?.info?.(
|
|
606
|
-
{
|
|
607
|
-
|
|
665
|
+
{
|
|
666
|
+
stream_id: newStreamId,
|
|
667
|
+
thread_id: threadId,
|
|
668
|
+
cwd: session.cwd,
|
|
669
|
+
reattached: true,
|
|
670
|
+
subscribers: session.streamIds.size,
|
|
671
|
+
},
|
|
672
|
+
"codex session reattached (multi-terminal subscribe)",
|
|
608
673
|
)
|
|
609
674
|
// major#1: 新 stream へ未応答の承認要求を再送する (Claude の _replayPendingPermissions と同型)。
|
|
610
675
|
// 承認要求は app-server との in-flight callback で、履歴 hydrate には含まれない。旧 stream 宛に
|
|
@@ -719,9 +784,38 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
719
784
|
})
|
|
720
785
|
}
|
|
721
786
|
|
|
722
|
-
/**
|
|
723
|
-
|
|
724
|
-
|
|
787
|
+
/**
|
|
788
|
+
* reconcile 用の最終活性更新。browser 入力 / app-server 通知の両方で呼ぶ。
|
|
789
|
+
* 案B: stream_id を伴う browser→agent アクション(input/permission/interrupt/keepalive)では
|
|
790
|
+
* **端末別の活性(lastActivityByStream)も更新**し、gcDeadTerminals が「応答を返さない購読端末」
|
|
791
|
+
* だけを掃除できるようにする。app-server 通知(stream_id なし)は session 全体の活性のみ更新する。
|
|
792
|
+
*/
|
|
793
|
+
_touch(session, stream_id) {
|
|
794
|
+
if (!session) return
|
|
795
|
+
const now = Date.now()
|
|
796
|
+
session.lastActivityMs = now
|
|
797
|
+
if (stream_id && session.streamIds?.has(stream_id)) {
|
|
798
|
+
session.lastActivityByStream.set(stream_id, now)
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* 案B: sessions Map は各購読 stream_id → session の多対一なので、distinct な live session を
|
|
804
|
+
* 走査するときは必ずこれで dedup する(同一 session を購読端末数ぶん重複カウントしない)。
|
|
805
|
+
* @returns {object[]} 非 dead な distinct session の配列
|
|
806
|
+
*/
|
|
807
|
+
_liveSessions() {
|
|
808
|
+
return Array.from(new Set(this.sessions.values())).filter((s) => !s.dead)
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** 案B: この session を指す全購読 stream_id キーを sessions Map から外す(reap / exit 共通)。 */
|
|
812
|
+
_dropAllStreamKeys(session) {
|
|
813
|
+
const ids = session.streamIds ? Array.from(session.streamIds) : []
|
|
814
|
+
for (const sid of ids) {
|
|
815
|
+
if (this.sessions.get(sid) === session) this.sessions.delete(sid)
|
|
816
|
+
}
|
|
817
|
+
// primary が streamIds に含まれない異常時の保険。
|
|
818
|
+
if (this.sessions.get(session.stream_id) === session) this.sessions.delete(session.stream_id)
|
|
725
819
|
}
|
|
726
820
|
|
|
727
821
|
_onNotification(session, msg) {
|
|
@@ -733,11 +827,18 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
733
827
|
switch (msg.method) {
|
|
734
828
|
case "turn/started":
|
|
735
829
|
session.activeTurnId = msg.params?.turn?.id || null
|
|
830
|
+
// round4 major#2: 世代を前進させる (pump が fast-turn を activeTurnId だけでなく世代前進でも検知)。
|
|
831
|
+
session.turnGeneration += 1
|
|
832
|
+
// round3 major#2: 起動確定 → pump の turn-active barrier を解除する (次 item が steer へ進む)。
|
|
833
|
+
this._settleTurnActiveWaiter(session)
|
|
736
834
|
// 会話ターン境界で「新しさ」を更新(閲覧 keepalive では更新しない)。
|
|
737
835
|
session.lastTurnAtMs = Date.now()
|
|
738
836
|
break
|
|
739
837
|
case "turn/completed":
|
|
740
838
|
session.activeTurnId = null
|
|
839
|
+
// round3 major#2: turn/started を取りこぼしたまま turn/completed が来た異常時も barrier が
|
|
840
|
+
// 固着しないよう解除する (pump が次 item を fresh に turn/start できる)。
|
|
841
|
+
this._settleTurnActiveWaiter(session)
|
|
741
842
|
session.lastTurnAtMs = Date.now()
|
|
742
843
|
this._autoResolveStalePendingApprovals(session, msg.params?.turn?.id)
|
|
743
844
|
// Stage 5: turn 完了後の reap 予約は不要。以降 idle になれば周期 reconcile が
|
|
@@ -810,7 +911,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
810
911
|
_onClientExit(session, info) {
|
|
811
912
|
if (session.dead) return
|
|
812
913
|
session.dead = true
|
|
813
|
-
|
|
914
|
+
// round3: app-server が死んだら barrier を解除し queue を reject する(promise 宙吊り防止)。
|
|
915
|
+
this._settleTurnActiveWaiter(session)
|
|
916
|
+
this._drainRejectQueue(session, { reason: "queue_dead" })
|
|
917
|
+
// 案B: app-server が死んだら全購読 stream_id キーを Map から外す(1 端末分だけでは
|
|
918
|
+
// 他の購読キーが dangling で残る)。
|
|
919
|
+
this._dropAllStreamKeys(session)
|
|
814
920
|
// app-server の終了は必ず記録する。ターン実行中の exit は「rollout に output 欠落の
|
|
815
921
|
// custom_tool_call を残した可能性がある」= thread 汚染の予兆なので warn で出す
|
|
816
922
|
// (codex_core の `Custom tool call output is missing for call id: ...` の発生源)。
|
|
@@ -861,55 +967,177 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
861
967
|
const session = this._require(stream_id)
|
|
862
968
|
// Stage 5: _touch で lastActivityMs を更新 = LRU 先頭へ。ターン開始で activeTurnId が
|
|
863
969
|
// 立ち、reconcile は healthy busy を protected 扱いにするので実行中に kill されない。
|
|
864
|
-
|
|
970
|
+
// 案B: stream_id を渡し端末別活性も更新(死端末 GC が入力継続端末を掃除しない)。
|
|
971
|
+
this._touch(session, stream_id)
|
|
865
972
|
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
|
-
|
|
973
|
+
// round3 major#1: 全 input を **session 単位の単一 FIFO** に積み、単一 pump に処理させる。
|
|
974
|
+
// promise は pump が実 delivery で settle する (即 resolve しない)。
|
|
975
|
+
return new Promise((resolve, reject) => {
|
|
976
|
+
session.inputQueue.push({ input, model, effort, resolve, reject, retriedAsStart: false })
|
|
977
|
+
void this._pumpInputs(session)
|
|
978
|
+
})
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* round3 major#1 (案B / T06542): session 単位の単一 FIFO pump。**1 件ずつ直列**に処理し、
|
|
983
|
+
* 並行 turn/start・turn/steer や queue の追い越しを構造的に排除する (singleflight)。
|
|
984
|
+
* - 非 busy (activeTurnId 無し): turn/start で新ターン。成功後、次を steer にするため
|
|
985
|
+
* turn/started (activeTurnId 確定) を **timeout 付き**で待つ (round3 major#2: ハング防止)。
|
|
986
|
+
* - busy (activeTurnId 有り): turn/steer で追い打ち。
|
|
987
|
+
* - turn/steer 失敗 (expectedTurnId 不一致など): activeTurnId を捨て、同 item を **1 回だけ**
|
|
988
|
+
* turn/start にフォールバックして自己修復する (旧 self-heal を FIFO 内で継続。並行はしない)。
|
|
989
|
+
* 各 item の promise は実 delivery の RPC 結果で settle する。
|
|
990
|
+
*/
|
|
991
|
+
async _pumpInputs(session) {
|
|
992
|
+
if (session.pumping) return
|
|
993
|
+
session.pumping = true
|
|
994
|
+
try {
|
|
995
|
+
while (session.inputQueue.length > 0) {
|
|
996
|
+
if (session.dead) {
|
|
997
|
+
this._drainRejectQueue(session, { reason: "queue_dead" })
|
|
887
998
|
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
999
|
}
|
|
1000
|
+
const item = session.inputQueue[0]
|
|
1001
|
+
// round4 major#3: 試行種別 (steer/start) を **await の前に** 確定する。await 中に
|
|
1002
|
+
// turn/completed が来て session.activeTurnId が null に戻ると、catch 時点の値では steer 試行が
|
|
1003
|
+
// start と誤判定され、self-heal リトライがスキップされて item が誤 reject される。
|
|
1004
|
+
const attemptedSteer = !!session.activeTurnId
|
|
1005
|
+
try {
|
|
1006
|
+
if (attemptedSteer) {
|
|
1007
|
+
await session.client.request("turn/steer", {
|
|
1008
|
+
threadId: session.threadId,
|
|
1009
|
+
expectedTurnId: session.activeTurnId,
|
|
1010
|
+
input: item.input,
|
|
1011
|
+
})
|
|
1012
|
+
session.inputQueue.shift()
|
|
1013
|
+
item.resolve()
|
|
1014
|
+
} else {
|
|
1015
|
+
// round4 major#2: turn/start dispatch の **前に** 世代を控える。start 応答後に
|
|
1016
|
+
// turn/started(+completed) が pump 再開前に処理されても、世代前進で「ターンが始まった」を
|
|
1017
|
+
// 検知でき、誤 timeout しない。
|
|
1018
|
+
const genBefore = session.turnGeneration
|
|
1019
|
+
const params = { threadId: session.threadId, input: item.input }
|
|
1020
|
+
if (item.model) params.model = item.model
|
|
1021
|
+
if (item.effort) params.effort = item.effort
|
|
1022
|
+
await session.client.request("turn/start", params)
|
|
1023
|
+
session.inputQueue.shift()
|
|
1024
|
+
item.resolve()
|
|
1025
|
+
// 次の item を steer にするため turn/started (= 世代前進) を待つ。timeout でハングを防ぐ。
|
|
1026
|
+
// timeout したら「ターンが正しく始まらなかった」とみなし残り queue を reject して打ち切る。
|
|
1027
|
+
const becameActive = await this._waitTurnActive(session, genBefore)
|
|
1028
|
+
if (!becameActive) {
|
|
1029
|
+
this._drainRejectQueue(session, { reason: "turn_start_timeout" })
|
|
1030
|
+
return
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
} catch (err) {
|
|
1034
|
+
if (attemptedSteer && !item.retriedAsStart) {
|
|
1035
|
+
// turn/steer 失敗 → 自己修復: activeTurnId を捨て、同 item を turn/start で 1 回だけリトライ
|
|
1036
|
+
// (shift しない = 同 item を次ループの start 経路で再処理)。並行はしない (単一 pump)。
|
|
1037
|
+
// 元の steer エラーを stash して、リトライも失敗したとき合成メッセージを作れるようにする。
|
|
1038
|
+
session.activeTurnId = null
|
|
1039
|
+
item.retriedAsStart = true
|
|
1040
|
+
item.priorSteerError = err instanceof Error ? err : new Error(String(err))
|
|
1041
|
+
continue
|
|
1042
|
+
}
|
|
1043
|
+
// start 失敗 or リトライ済み steer 失敗 → この item を reject。activeTurnId は捨てる
|
|
1044
|
+
// (ターンは始まっていない/不明)。次の item は fresh に turn/start する。
|
|
1045
|
+
session.inputQueue.shift()
|
|
1046
|
+
session.activeTurnId = null
|
|
1047
|
+
let e
|
|
1048
|
+
if (item.retriedAsStart && item.priorSteerError) {
|
|
1049
|
+
// steer→start リトライがともに失敗: 合成メッセージ + reason (main.mjs の classifyCodexInputError
|
|
1050
|
+
// が reason を優先採用。従来の単一 input() 実装と同じ診断を保つ)。
|
|
1051
|
+
e = Object.assign(
|
|
1052
|
+
new Error(
|
|
1053
|
+
`turn/steer failed (${item.priorSteerError.message}) and turn/start retry also failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1054
|
+
),
|
|
1055
|
+
{ reason: "turn_mismatch_retry_failed", cause: err },
|
|
1056
|
+
)
|
|
1057
|
+
} else {
|
|
1058
|
+
e = err instanceof Error ? err : new Error(String(err))
|
|
1059
|
+
if (!e.reason) e.reason = "start_error"
|
|
1060
|
+
}
|
|
1061
|
+
item.reject(e)
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
} finally {
|
|
1065
|
+
session.pumping = false
|
|
1066
|
+
// pumping=false 直後の enqueue 競合窓を拾い直す (取りこぼし防止)。再帰は 1 段のみ。
|
|
1067
|
+
if (session.inputQueue.length > 0 && !session.dead) {
|
|
1068
|
+
void this._pumpInputs(session)
|
|
901
1069
|
}
|
|
902
1070
|
}
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* round3 major#2 + round4 major#2: turn/start 後に「ターンが始まった」ことを timeout 付きで待つ
|
|
1075
|
+
* barrier。activeTurnId が立っているか、または **turn/start dispatch 前に控えた世代 genBefore から
|
|
1076
|
+
* 世代が前進している** (fast-turn で started+completed 済み) なら即 true を返し、誤 timeout しない。
|
|
1077
|
+
* @param {object} session
|
|
1078
|
+
* @param {number} [genBefore] turn/start dispatch 直前の session.turnGeneration
|
|
1079
|
+
* @returns {Promise<boolean>} ターンが始まったら true、timeout したら false。
|
|
1080
|
+
*/
|
|
1081
|
+
_waitTurnActive(session, genBefore = -1) {
|
|
1082
|
+
if (session.activeTurnId || session.turnGeneration > genBefore) return Promise.resolve(true)
|
|
1083
|
+
return new Promise((resolve) => {
|
|
1084
|
+
const timer = setTimeout(() => {
|
|
1085
|
+
if (session._turnActiveWaiter && session._turnActiveWaiter.timer === timer) {
|
|
1086
|
+
session._turnActiveWaiter = null
|
|
1087
|
+
}
|
|
1088
|
+
resolve(false)
|
|
1089
|
+
}, this._turnStartTimeoutMs)
|
|
1090
|
+
// minor (round4): 通知を出さない単体テスト等でプロセスを timeout 分保持しないよう unref する。
|
|
1091
|
+
if (typeof timer.unref === "function") timer.unref()
|
|
1092
|
+
// 既存 waiter があれば置換前に false 解決しておく (通常は無い)。
|
|
1093
|
+
if (session._turnActiveWaiter) {
|
|
1094
|
+
try {
|
|
1095
|
+
clearTimeout(session._turnActiveWaiter.timer)
|
|
1096
|
+
} catch {
|
|
1097
|
+
/* noop */
|
|
1098
|
+
}
|
|
1099
|
+
session._turnActiveWaiter.resolve(false)
|
|
1100
|
+
}
|
|
1101
|
+
session._turnActiveWaiter = { resolve: () => resolve(true), timer }
|
|
1102
|
+
})
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/** round3 major#2: turn/started / completed / exit / 死亡で待機中の barrier を解除する。 */
|
|
1106
|
+
_settleTurnActiveWaiter(session) {
|
|
1107
|
+
const w = session._turnActiveWaiter
|
|
1108
|
+
if (!w) return
|
|
1109
|
+
session._turnActiveWaiter = null
|
|
1110
|
+
try {
|
|
1111
|
+
clearTimeout(w.timer)
|
|
1112
|
+
} catch {
|
|
1113
|
+
/* noop */
|
|
1114
|
+
}
|
|
1115
|
+
w.resolve()
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* round3: FIFO の未処理 item を一括 reject する (session 死亡 / turn_start timeout で pump が
|
|
1120
|
+
* 続行できないとき。ack も nack も返らない配達喪失を防ぐ)。
|
|
1121
|
+
*/
|
|
1122
|
+
_drainRejectQueue(session, cause) {
|
|
1123
|
+
if (!session || !Array.isArray(session.inputQueue) || session.inputQueue.length === 0) return
|
|
1124
|
+
const pending = session.inputQueue
|
|
1125
|
+
session.inputQueue = []
|
|
1126
|
+
const reason = cause?.reason || "queue_dead"
|
|
1127
|
+
for (const { reject } of pending) {
|
|
1128
|
+
reject(
|
|
1129
|
+
Object.assign(new Error("codex input dropped: turn could not be delivered"), {
|
|
1130
|
+
reason,
|
|
1131
|
+
cause,
|
|
1132
|
+
}),
|
|
1133
|
+
)
|
|
1134
|
+
}
|
|
907
1135
|
}
|
|
908
1136
|
|
|
909
1137
|
/** @returns {Promise<void>} */
|
|
910
1138
|
async interrupt({ stream_id }) {
|
|
911
1139
|
const session = this._require(stream_id)
|
|
912
|
-
this._touch(session)
|
|
1140
|
+
this._touch(session, stream_id)
|
|
913
1141
|
if (!session.activeTurnId) return
|
|
914
1142
|
await session.client.request("turn/interrupt", {
|
|
915
1143
|
threadId: session.threadId,
|
|
@@ -920,7 +1148,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
920
1148
|
/** サーバー発の承認/入力要求への応答。result の shape は method ごとに呼び出し側が組み立てる。 */
|
|
921
1149
|
permissionReply({ stream_id, request_id, result }) {
|
|
922
1150
|
const session = this._require(stream_id)
|
|
923
|
-
|
|
1151
|
+
// 案B エッジ②: 先着 1 応答で解決。stream_id を渡し応答端末の活性を更新(死端末 GC 対象外に)。
|
|
1152
|
+
this._touch(session, stream_id)
|
|
924
1153
|
if (!session.pendingApprovals.has(request_id)) {
|
|
925
1154
|
this.logger?.warn?.({ stream_id, request_id }, "codex permissionReply: unknown request_id (ignored)")
|
|
926
1155
|
return
|
|
@@ -941,18 +1170,48 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
941
1170
|
keepalive({ stream_id }) {
|
|
942
1171
|
const session = this.sessions.get(stream_id)
|
|
943
1172
|
if (!session || session.dead) return false
|
|
944
|
-
this._touch(session)
|
|
1173
|
+
this._touch(session, stream_id)
|
|
945
1174
|
return true
|
|
946
1175
|
}
|
|
947
1176
|
|
|
948
1177
|
/**
|
|
949
|
-
* グレースフル切断(Stage 5)。**すぐには reap しない**。
|
|
950
|
-
*
|
|
951
|
-
*
|
|
1178
|
+
* グレースフル切断(Stage 5 + 案B T06542)。**すぐには reap しない**。
|
|
1179
|
+
*
|
|
1180
|
+
* 案B: detach は「この端末分の購読解除」。まだ他端末が購読していればセッションは生かし、
|
|
1181
|
+
* detachRequested も立てない(別端末が見ているため warm ではなく現役)。最後の 1 台が外れた
|
|
1182
|
+
* ときだけ detachRequested を立て warm 保持し、以降は周期 reconcile(LRU 上限 / 長 idle TTL /
|
|
1183
|
+
* stuck)に委ねる。これにより「別 CLI 画面へ切替えた瞬間 5 分で殺される」問題を解消しつつ、
|
|
1184
|
+
* 多端末購読中に 1 タブ閉じても残りの端末のライブ表示が切れない。
|
|
952
1185
|
*/
|
|
953
1186
|
detach({ stream_id }) {
|
|
954
1187
|
const session = this.sessions.get(stream_id)
|
|
955
1188
|
if (!session || session.dead) return
|
|
1189
|
+
// ⚠️ 不変条件(案B): **live(非 dead)session は常に stream key を 1 つ以上持つ**。codex は
|
|
1190
|
+
// claude と違い warm セッションの唯一の索引が this.sessions なので、最後の 1 端末分の key まで
|
|
1191
|
+
// 消すと reconcile が到達できず(reap されずリーク)、_findLiveSessionByCwd の reattach 引き当ても
|
|
1192
|
+
// 効かなくなる。よって「購読が 2 つ以上のときだけ実際に外す」。
|
|
1193
|
+
if (session.streamIds.size > 1) {
|
|
1194
|
+
// 他端末がまだ購読中: この端末分の購読解除のみ。セッションは残りの端末が生かす。
|
|
1195
|
+
session.streamIds.delete(stream_id)
|
|
1196
|
+
session.lastActivityByStream.delete(stream_id)
|
|
1197
|
+
if (this.sessions.get(stream_id) === session) this.sessions.delete(stream_id)
|
|
1198
|
+
// primary を生存端末へ寄せ替える(末尾=最も新しく購読した端末を .at(-1) で明示選択)。
|
|
1199
|
+
if (session.stream_id === stream_id) {
|
|
1200
|
+
session.stream_id = Array.from(session.streamIds).at(-1)
|
|
1201
|
+
}
|
|
1202
|
+
this.logger?.info?.(
|
|
1203
|
+
{
|
|
1204
|
+
stream_id,
|
|
1205
|
+
thread_id: session.threadId,
|
|
1206
|
+
cwd: session.cwd,
|
|
1207
|
+
remaining_subscribers: session.streamIds.size,
|
|
1208
|
+
},
|
|
1209
|
+
"codex terminal unsubscribed (session kept; other terminals still watching)",
|
|
1210
|
+
)
|
|
1211
|
+
return
|
|
1212
|
+
}
|
|
1213
|
+
// 最後の 1 台が外れた: key / streamIds は残したまま(reconcile 到達性のため)warm 保持し、
|
|
1214
|
+
// detachRequested を立てる。以降は周期 reconcile(LRU 上限 / 長 idle TTL / stuck)が reap を判断する。
|
|
956
1215
|
session.detachRequested = true
|
|
957
1216
|
this.logger?.info?.(
|
|
958
1217
|
{
|
|
@@ -961,10 +1220,55 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
961
1220
|
cwd: session.cwd,
|
|
962
1221
|
busy: !!session.activeTurnId,
|
|
963
1222
|
},
|
|
964
|
-
"codex detach requested by browser (kept warm; reconcile handles reap)",
|
|
1223
|
+
"codex detach requested by browser (last terminal; kept warm; reconcile handles reap)",
|
|
965
1224
|
)
|
|
966
1225
|
}
|
|
967
1226
|
|
|
1227
|
+
/**
|
|
1228
|
+
* 案B (T06542): 死端末 GC。多端末購読で、クラッシュ / タブ強制終了で codex.detach を送れずに
|
|
1229
|
+
* 消えた購読端末(一定期間 input/permission/interrupt/keepalive の活性が無い stream_id)を購読集合
|
|
1230
|
+
* から掃除する。claude-stream-bridge.mjs の gcDeadTerminals と同型。複数端末が購読するセッション
|
|
1231
|
+
* のみ対象(1 台しか居なければ reconcile の idle TTL / LRU に任せる)。全端末が死端末で空になったら
|
|
1232
|
+
* detachRequested を立て warm 保持へ落とす。reconcileSessions の先頭から呼ばれる(main.mjs の
|
|
1233
|
+
* state loop 経由)ほか、テストから直接も呼べる。
|
|
1234
|
+
* @param {number} [ttlMs] 死端末とみなす無活性閾値(テスト用に上書き可)。
|
|
1235
|
+
* @returns {number} GC した端末キー数。
|
|
1236
|
+
*/
|
|
1237
|
+
gcDeadTerminals(ttlMs = this._deadTerminalTtlMs) {
|
|
1238
|
+
const now = Date.now()
|
|
1239
|
+
let removed = 0
|
|
1240
|
+
for (const session of this._liveSessions()) {
|
|
1241
|
+
// 複数端末が購読しているセッションのみ対象(1 台なら reconcile の idle TTL / LRU に任せる)。
|
|
1242
|
+
if (session.streamIds.size <= 1) continue
|
|
1243
|
+
for (const sid of Array.from(session.streamIds)) {
|
|
1244
|
+
// ⚠️ 不変条件: live session は常に stream key を 1 つ以上残す(reconcile 到達性)。全端末が
|
|
1245
|
+
// 死端末でも最後の 1 個は消さず、下で detachRequested を立てて idle TTL reap に委ねる。
|
|
1246
|
+
if (session.streamIds.size <= 1) break
|
|
1247
|
+
const last = session.lastActivityByStream.get(sid) ?? 0
|
|
1248
|
+
if (now - last < ttlMs) continue
|
|
1249
|
+
session.streamIds.delete(sid)
|
|
1250
|
+
session.lastActivityByStream.delete(sid)
|
|
1251
|
+
if (this.sessions.get(sid) === session) this.sessions.delete(sid)
|
|
1252
|
+
removed += 1
|
|
1253
|
+
if (session.stream_id === sid) {
|
|
1254
|
+
session.stream_id = Array.from(session.streamIds).at(-1)
|
|
1255
|
+
}
|
|
1256
|
+
this.logger?.info?.(
|
|
1257
|
+
{ stream_id: sid, thread_id: session.threadId, cwd: session.cwd },
|
|
1258
|
+
"codex dead terminal GC'd (no activity past TTL)",
|
|
1259
|
+
)
|
|
1260
|
+
}
|
|
1261
|
+
// 生き残った最後の 1 端末も死端末(無活性が TTL 超過)なら warm 保持へ落とす
|
|
1262
|
+
// (key は残したまま。reconcile が idle TTL / LRU で最終的に reap する)。
|
|
1263
|
+
if (session.streamIds.size === 1) {
|
|
1264
|
+
const [onlySid] = session.streamIds
|
|
1265
|
+
const last = session.lastActivityByStream.get(onlySid) ?? 0
|
|
1266
|
+
if (now - last >= ttlMs) session.detachRequested = true
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return removed
|
|
1270
|
+
}
|
|
1271
|
+
|
|
968
1272
|
/**
|
|
969
1273
|
* 診断用: 生きているセッションの一覧 (sibling-inject の失敗理由に載せる / ログ用)。
|
|
970
1274
|
* 「TUI codex は hub-agent の管理外なので ask-sibling の宛先にならない」ことを
|
|
@@ -978,8 +1282,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
978
1282
|
describeLiveSessions(cwd) {
|
|
979
1283
|
const now = Date.now()
|
|
980
1284
|
const out = []
|
|
981
|
-
|
|
982
|
-
|
|
1285
|
+
// 案B: sessions は多対一なので distinct session で走査する(購読端末数ぶん重複させない)。
|
|
1286
|
+
for (const session of this._liveSessions()) {
|
|
983
1287
|
if (cwd && session.cwd !== cwd) continue
|
|
984
1288
|
out.push({
|
|
985
1289
|
kind: "codex",
|
|
@@ -1034,8 +1338,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1034
1338
|
if (!cwd || !text) return false
|
|
1035
1339
|
let best = null
|
|
1036
1340
|
let bestAt = -1
|
|
1037
|
-
|
|
1038
|
-
|
|
1341
|
+
// 案B: distinct session で走査(多対一 Map の重複を除く)。
|
|
1342
|
+
for (const session of this._liveSessions()) {
|
|
1343
|
+
if (session.cwd !== cwd) continue
|
|
1039
1344
|
// 宛先選定の「最新」は会話ターン境界時刻で判定する(閲覧 keepalive で動く
|
|
1040
1345
|
// lastActivityMs を使うと「見ているだけの古い会話」が本命を追い越す)。
|
|
1041
1346
|
const at = session.lastTurnAtMs ?? session.lastActivityMs ?? 0
|
|
@@ -1109,9 +1414,11 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1109
1414
|
if (!cwd || !id || !text) return { status: "not_found" }
|
|
1110
1415
|
let bySid = null
|
|
1111
1416
|
const byThread = []
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1417
|
+
// 案B: distinct session で走査。id は **任意の購読 stream_id**(primary とは限らない)に
|
|
1418
|
+
// 一致し得るので streamIds.has(id) で判定する(多端末購読の非 primary 端末も宛先にできる)。
|
|
1419
|
+
for (const session of this._liveSessions()) {
|
|
1420
|
+
if (session.cwd !== cwd) continue
|
|
1421
|
+
if (session.streamIds.has(id)) {
|
|
1115
1422
|
bySid = session
|
|
1116
1423
|
break // stream_id は一意なので即確定
|
|
1117
1424
|
}
|
|
@@ -1169,7 +1476,11 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1169
1476
|
_reapSession(session, now, reason) {
|
|
1170
1477
|
if (session.dead) return
|
|
1171
1478
|
session.dead = true
|
|
1172
|
-
|
|
1479
|
+
// round3: reap した session の barrier を解除し queue を reject する(promise 宙吊り防止)。
|
|
1480
|
+
this._settleTurnActiveWaiter(session)
|
|
1481
|
+
this._drainRejectQueue(session, { reason: "queue_dead" })
|
|
1482
|
+
// 案B: 全購読 stream_id キーを Map から外す(primary 1 個だけ消すと dangling が残る)。
|
|
1483
|
+
this._dropAllStreamKeys(session)
|
|
1173
1484
|
try {
|
|
1174
1485
|
session.client.stop()
|
|
1175
1486
|
} catch (err) {
|
|
@@ -1182,7 +1493,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1182
1493
|
cwd: session.cwd,
|
|
1183
1494
|
reason,
|
|
1184
1495
|
idle_ms: now - (session.lastActivityMs ?? now),
|
|
1185
|
-
live_after: this.
|
|
1496
|
+
live_after: this._liveSessions().length,
|
|
1186
1497
|
},
|
|
1187
1498
|
"codex session reaped",
|
|
1188
1499
|
)
|
|
@@ -1203,7 +1514,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1203
1514
|
* @returns {number} 退避した数
|
|
1204
1515
|
*/
|
|
1205
1516
|
_evictWarmPoolOverflow(now, reason = "lru-evict") {
|
|
1206
|
-
|
|
1517
|
+
// 案B: warm pool 上限は「distinct な生存 session 数」で判定する(購読端末数ではない)。
|
|
1518
|
+
const live = this._liveSessions()
|
|
1207
1519
|
const overflow = live.length - this._warmPoolMax
|
|
1208
1520
|
if (overflow <= 0) return 0
|
|
1209
1521
|
const evictable = live
|
|
@@ -1244,9 +1556,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1244
1556
|
reconcileSessions() {
|
|
1245
1557
|
const now = Date.now()
|
|
1246
1558
|
let reaped = 0
|
|
1559
|
+
// 案B: 0. 死端末 GC を先に回す(多端末購読で応答が途絶えた端末を購読集合から掃除)。これで
|
|
1560
|
+
// 「全端末が死んで空になったセッション」が下の idle TTL 経路で正しく reap 対象になる。
|
|
1561
|
+
this.gcDeadTerminals()
|
|
1247
1562
|
// 1〜2: stuck turn / long idle。protected(healthy running turn)はスキップ。
|
|
1248
|
-
|
|
1249
|
-
|
|
1563
|
+
// 案B: distinct session で走査(多対一 Map の重複 reap を避ける)。
|
|
1564
|
+
for (const session of this._liveSessions()) {
|
|
1250
1565
|
if (this._isProtected(session, now)) continue
|
|
1251
1566
|
const idle = now - (session.lastActivityMs ?? now)
|
|
1252
1567
|
if (session.activeTurnId && idle > this._stuckTurnTtlMs) {
|
|
@@ -1269,8 +1584,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1269
1584
|
|
|
1270
1585
|
/** 全セッションを即座に終了する (agent shutdown 用。ターン完走は待たない)。 */
|
|
1271
1586
|
shutdown() {
|
|
1272
|
-
|
|
1587
|
+
// 案B: distinct session で走査(多対一 Map の重複 stop を避ける)。
|
|
1588
|
+
for (const session of this._liveSessions()) {
|
|
1273
1589
|
session.dead = true
|
|
1590
|
+
// round3: shutdown で barrier を解除し queue 入力を reject(promise 宙吊り防止)。
|
|
1591
|
+
this._settleTurnActiveWaiter(session)
|
|
1592
|
+
this._drainRejectQueue(session, { reason: "queue_dead" })
|
|
1274
1593
|
// hub-agent 再起動時にターン実行中のセッションを落とすと thread が汚染されうる
|
|
1275
1594
|
// (exec 中の kill → output 欠落)。因果を後から辿れるよう必ず記録する。
|
|
1276
1595
|
this.logger?.[session.activeTurnId ? "warn" : "info"]?.(
|
package/src/main.mjs
CHANGED
|
@@ -2064,9 +2064,9 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2064
2064
|
const tick = async () => {
|
|
2065
2065
|
if (stopped) return
|
|
2066
2066
|
try {
|
|
2067
|
-
// B11:
|
|
2067
|
+
// B11: 多端末共有 (T06542 で常時) で、クラッシュして detach を送らず消えた死端末を GC する
|
|
2068
2068
|
// (活性なし TTL 超過の購読端末キーを掃除し、最後の 1 台が外れたら idle softDetach へ)。
|
|
2069
|
-
//
|
|
2069
|
+
// 例外は state loop 全体の try/catch が拾う。
|
|
2070
2070
|
try {
|
|
2071
2071
|
claudeBridge?.gcDeadTerminals?.()
|
|
2072
2072
|
} catch (err) {
|
|
@@ -2559,6 +2559,13 @@ function makeSeenInputIdRegistry(max = SEEN_INPUT_IDS_MAX) {
|
|
|
2559
2559
|
const seenClaudeChatInputIds = makeSeenInputIdRegistry()
|
|
2560
2560
|
const seenCodexChatInputIds = makeSeenInputIdRegistry()
|
|
2561
2561
|
|
|
2562
|
+
// round4 major#1 (T06542): codex.input の **in-flight** dedup 用レジストリ (input_id -> Promise)。
|
|
2563
|
+
// `seen` は settle 後にしか登録されないため、frontend の ack watchdog (5s) が bridge の barrier/RPC
|
|
2564
|
+
// (turn/start→turn/started は数秒〜) より短く、同一 input_id が settle 前に再送されると bridge.input を
|
|
2565
|
+
// 二重に呼んで FIFO へ重複投入され、二重ターン/steer になる。処理中の input_id は再 dispatch せず既存
|
|
2566
|
+
// Promise へ合流させる (settle 時に元の .then/.catch が 1 回だけ ack/nack を出す)。
|
|
2567
|
+
const codexInflightByInputId = new Map()
|
|
2568
|
+
|
|
2562
2569
|
/**
|
|
2563
2570
|
* input_id 付き pty.data (tracked 入力) の処理。write 成功で pty.input.ack を返し、
|
|
2564
2571
|
* stream 不在なら pty.error を返して browser の再 attach → 再送に繋げる。
|
|
@@ -2697,13 +2704,19 @@ export function handleCodexChatInput(msg, ctx) {
|
|
|
2697
2704
|
return undefined
|
|
2698
2705
|
}
|
|
2699
2706
|
if (input_id && seenCodexChatInputIds.has(input_id)) {
|
|
2700
|
-
// 再送 (ack 喪失想定)。turn/start・turn/steer を再送出せず ack のみ返す
|
|
2707
|
+
// 再送 (ack 喪失想定 / settle 済み)。turn/start・turn/steer を再送出せず ack のみ返す
|
|
2701
2708
|
// (二重ターン実行を避ける。claude.input dedup と同じ考え方)。
|
|
2702
2709
|
ctx.logger?.info?.({ stream_id, input_id }, "codex.input dedup (ack only)")
|
|
2703
2710
|
ctx.client.send({ type: "codex.input.ack", stream_id, input_id })
|
|
2704
2711
|
return undefined
|
|
2705
2712
|
}
|
|
2706
|
-
|
|
2713
|
+
if (input_id && codexInflightByInputId.has(input_id)) {
|
|
2714
|
+
// round4 major#1: **処理中** の同一 input_id (watchdog の settle 前再送)。再 dispatch せず既存
|
|
2715
|
+
// Promise へ合流する (元の .then/.catch が settle 時に 1 回だけ ack/nack を出すので二重投入しない)。
|
|
2716
|
+
ctx.logger?.info?.({ stream_id, input_id }, "codex.input in-flight dedup (join, no re-dispatch)")
|
|
2717
|
+
return codexInflightByInputId.get(input_id)
|
|
2718
|
+
}
|
|
2719
|
+
const p = ctx.codexBridge
|
|
2707
2720
|
.input({
|
|
2708
2721
|
stream_id,
|
|
2709
2722
|
message: msg.message,
|
|
@@ -2735,6 +2748,12 @@ export function handleCodexChatInput(msg, ctx) {
|
|
|
2735
2748
|
...(err.reason ? { reason: err.reason } : {}),
|
|
2736
2749
|
})
|
|
2737
2750
|
})
|
|
2751
|
+
.finally(() => {
|
|
2752
|
+
// in-flight レジストリから外す (成功時は seen が以降の再送を dedup、失敗時は再送で正当にリトライ)。
|
|
2753
|
+
if (input_id) codexInflightByInputId.delete(input_id)
|
|
2754
|
+
})
|
|
2755
|
+
if (input_id) codexInflightByInputId.set(input_id, p)
|
|
2756
|
+
return p
|
|
2738
2757
|
}
|
|
2739
2758
|
|
|
2740
2759
|
/**
|
package/src/service-install.mjs
CHANGED
|
@@ -15,8 +15,6 @@ import path from "node:path"
|
|
|
15
15
|
import { spawnSync } from "node:child_process"
|
|
16
16
|
import { fileURLToPath } from "node:url"
|
|
17
17
|
|
|
18
|
-
import { readConfig } from "./config.mjs"
|
|
19
|
-
|
|
20
18
|
const PLIST_LABEL = "co.cocorograph.hub-agent"
|
|
21
19
|
const SYSTEMD_UNIT_NAME = "hub-agent.service"
|
|
22
20
|
// Windows(WSL) のログオン keep-alive タスク名 (install.ps1 の $TASK_NAME と一致)。
|
|
@@ -55,7 +53,7 @@ function escapeXmlText(s) {
|
|
|
55
53
|
.replaceAll(">", ">")
|
|
56
54
|
}
|
|
57
55
|
|
|
58
|
-
function expandTemplate(text, hubAgentBin
|
|
56
|
+
function expandTemplate(text, hubAgentBin) {
|
|
59
57
|
// hub-agent デーモンの runtime 環境に必須な追加 env を、各プラットフォームの
|
|
60
58
|
// 起動ファイル形式 (plist / systemd) に合わせて差し込むためのプレースホルダ展開。
|
|
61
59
|
//
|
|
@@ -78,22 +76,8 @@ function expandTemplate(text, hubAgentBin, opts = {}) {
|
|
|
78
76
|
systemdLine = `Environment=NODE_EXTRA_CA_CERTS=${nodeExtraCa}\n`
|
|
79
77
|
}
|
|
80
78
|
|
|
81
|
-
//
|
|
82
|
-
// plist
|
|
83
|
-
// が agent.json.chat_shared を読んで渡す opts.chatShared、または (b) install-service
|
|
84
|
-
// 実行時の process.env.HUB_AGENT_CHAT_SHARED。これにより agent 更新で install-service
|
|
85
|
-
// が plist を再生成しても、agent.json に chat_shared=true があればフラグが維持される
|
|
86
|
-
// (旧実装は手動 plist 追記が再生成で消える事故があった。2026-05-30)。
|
|
87
|
-
const chatShared =
|
|
88
|
-
opts.chatShared === true || process.env.HUB_AGENT_CHAT_SHARED === "1"
|
|
89
|
-
let chatPlistEntry = ""
|
|
90
|
-
let chatSystemdLine = ""
|
|
91
|
-
if (chatShared) {
|
|
92
|
-
chatPlistEntry =
|
|
93
|
-
` <key>HUB_AGENT_CHAT_SHARED</key>\n` +
|
|
94
|
-
` <string>1</string>\n`
|
|
95
|
-
chatSystemdLine = `Environment=HUB_AGENT_CHAT_SHARED=1\n`
|
|
96
|
-
}
|
|
79
|
+
// T06542: 多端末共有チャットは既定 ON 化しフラグ (HUB_AGENT_CHAT_SHARED) を撤去した。
|
|
80
|
+
// plist/systemd への env 焼き込みも不要になったため、テンプレートの placeholder ごと削除済み。
|
|
97
81
|
|
|
98
82
|
return text
|
|
99
83
|
.replaceAll("__HUB_AGENT_BIN__", hubAgentBin)
|
|
@@ -101,19 +85,6 @@ function expandTemplate(text, hubAgentBin, opts = {}) {
|
|
|
101
85
|
.replaceAll("__PATH__", process.env.PATH || "/usr/local/bin:/usr/bin:/bin")
|
|
102
86
|
.replaceAll("__NODE_EXTRA_CA_CERTS_PLIST_ENTRY__", plistEntry)
|
|
103
87
|
.replaceAll("__NODE_EXTRA_CA_CERTS_SYSTEMD_LINE__", systemdLine)
|
|
104
|
-
.replaceAll("__HUB_AGENT_CHAT_SHARED_PLIST_ENTRY__", chatPlistEntry)
|
|
105
|
-
.replaceAll("__HUB_AGENT_CHAT_SHARED_SYSTEMD_LINE__", chatSystemdLine)
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** agent.json から多端末共有フラグ (chat_shared) を読む。読めなければ false。
|
|
109
|
-
* install-service 時に plist/systemd へ焼き込む値ソースに使う (env 併用は expandTemplate 側)。 */
|
|
110
|
-
async function readChatSharedFromConfig() {
|
|
111
|
-
try {
|
|
112
|
-
const config = await readConfig()
|
|
113
|
-
return config?.chat_shared === true
|
|
114
|
-
} catch {
|
|
115
|
-
return false
|
|
116
|
-
}
|
|
117
88
|
}
|
|
118
89
|
|
|
119
90
|
async function ensureDir(p) {
|
|
@@ -290,12 +261,9 @@ export async function installService({ bin } = {}) {
|
|
|
290
261
|
const hubAgentBin = bin || detectHubAgentBin()
|
|
291
262
|
await ensureLogFile()
|
|
292
263
|
|
|
293
|
-
// agent.json の永続フラグを読み、テンプレ展開に渡す (再生成でも維持されるように)。
|
|
294
|
-
const chatShared = await readChatSharedFromConfig()
|
|
295
|
-
|
|
296
264
|
if (process.platform === "darwin") {
|
|
297
265
|
const tpl = await readTemplate("co.cocorograph.hub-agent.plist")
|
|
298
|
-
const expanded = expandTemplate(tpl, hubAgentBin
|
|
266
|
+
const expanded = expandTemplate(tpl, hubAgentBin)
|
|
299
267
|
const dest = macPlistPath()
|
|
300
268
|
await ensureDir(path.dirname(dest))
|
|
301
269
|
await fs.writeFile(dest, expanded, { mode: 0o644 })
|
|
@@ -317,7 +285,7 @@ export async function installService({ bin } = {}) {
|
|
|
317
285
|
|
|
318
286
|
if (process.platform === "linux") {
|
|
319
287
|
const tpl = await readTemplate("hub-agent.service")
|
|
320
|
-
const expanded = expandTemplate(tpl, hubAgentBin
|
|
288
|
+
const expanded = expandTemplate(tpl, hubAgentBin)
|
|
321
289
|
const dest = linuxUnitPath()
|
|
322
290
|
await ensureDir(path.dirname(dest))
|
|
323
291
|
await fs.writeFile(dest, expanded, { mode: 0o644 })
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
<string>__PATH__</string>
|
|
53
53
|
<key>HOME</key>
|
|
54
54
|
<string>__HOME__</string>
|
|
55
|
-
|
|
55
|
+
__NODE_EXTRA_CA_CERTS_PLIST_ENTRY__ </dict>
|
|
56
56
|
|
|
57
57
|
<!-- KeepAlive で過剰再起動した時に 10 秒スロットルする -->
|
|
58
58
|
<key>ThrottleInterval</key>
|
|
@@ -12,7 +12,7 @@ Type=simple
|
|
|
12
12
|
# 行に、未 set なら空文字列に」展開する。
|
|
13
13
|
Environment=PATH=__PATH__
|
|
14
14
|
Environment=HOME=__HOME__
|
|
15
|
-
|
|
15
|
+
__NODE_EXTRA_CA_CERTS_SYSTEMD_LINE__WorkingDirectory=__HOME__
|
|
16
16
|
ExecStart=__HUB_AGENT_BIN__ start
|
|
17
17
|
Restart=always
|
|
18
18
|
RestartSec=10
|