@cocorograph/hub-agent 0.7.78 → 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 は
|
|
@@ -177,6 +196,15 @@ function flattenThreadItems(thread, { maxItems = HISTORY_MAX_ITEMS, maxBytes = H
|
|
|
177
196
|
return { items: kept, truncated, total }
|
|
178
197
|
}
|
|
179
198
|
|
|
199
|
+
/** 履歴 item の概算 byte 数 (flattenThreadItems の budget 判定と同じ JSON 長ベース)。 */
|
|
200
|
+
function estimateItemBytes(item) {
|
|
201
|
+
try {
|
|
202
|
+
return JSON.stringify(item)?.length || 0
|
|
203
|
+
} catch {
|
|
204
|
+
return 0
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
180
208
|
/**
|
|
181
209
|
* resume 応答から Codex の「権威的なターン進行状態」を取り出す。
|
|
182
210
|
*
|
|
@@ -264,6 +292,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
264
292
|
detachTtlMs,
|
|
265
293
|
warmPoolMax,
|
|
266
294
|
stuckTurnTtlMs,
|
|
295
|
+
deadTerminalTtlMs,
|
|
296
|
+
turnStartTimeoutMs,
|
|
267
297
|
rolloutPresence,
|
|
268
298
|
historyMaxItems,
|
|
269
299
|
historyMaxBytes,
|
|
@@ -278,6 +308,10 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
278
308
|
this._warmPoolMax = warmPoolMax ?? WARM_POOL_MAX
|
|
279
309
|
// Stage 5: stuck turn 検出 TTL(既定 1h)。テストから短縮可能。
|
|
280
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
|
|
281
315
|
// #4 失敗分類の rollout 存在チェック (tri-state。テストから注入可能)。
|
|
282
316
|
this._rolloutPresence = rolloutPresence || codexRolloutPresence
|
|
283
317
|
// T06499: 履歴 hydrate の上限 (テストから小さい値を注入可能)。
|
|
@@ -332,6 +366,35 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
332
366
|
}
|
|
333
367
|
}
|
|
334
368
|
|
|
369
|
+
// T06541 案1: attach_id が無くても、同一 (cwd, resume_thread_id) のライブ session があれば
|
|
370
|
+
// in-memory reattach する。frontend は attach_id を送らないため上の attachId 経路は発火せず、
|
|
371
|
+
// これが実質のメイン reattach 経路になる。新プロセス thread/resume を起動せず所有権を付け替え、
|
|
372
|
+
// **蓄積済みの高忠実度履歴を返す**(frontend は remount で白紙化するため履歴が必要。reducer は
|
|
373
|
+
// uuid dedup で二重描画しない)。これにより症状⑦(生成中に移動→戻ると turn_active=false)が
|
|
374
|
+
// 解消し、停止/完了通知も同一プロセスに揃う。同一会話 (threadId 一致) のみ対象。
|
|
375
|
+
if (resumeThreadId) {
|
|
376
|
+
const existing = this._findLiveSessionByCwd(cwd, resumeThreadId)
|
|
377
|
+
if (existing && existing.stream_id !== stream_id) {
|
|
378
|
+
const r = await this._tryReattach(existing, stream_id)
|
|
379
|
+
if (r.status === "reattached") {
|
|
380
|
+
return {
|
|
381
|
+
...r.info,
|
|
382
|
+
// 案1: reattach は履歴を返さない設計 (r.info.history=[]) だが、cwd reattach は remount
|
|
383
|
+
// 由来で表示がリセットされ得るため、live セッションが保持する高忠実度履歴を返す。
|
|
384
|
+
history: Array.isArray(existing.historyItems) ? existing.historyItems.slice() : [],
|
|
385
|
+
historyTruncated: existing.historyTruncated === true,
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (r.status === "superseded") {
|
|
389
|
+
const err = new Error("attach superseded by a concurrent reattach")
|
|
390
|
+
err.attachOperation = "reattach"
|
|
391
|
+
err.attachReason = "superseded"
|
|
392
|
+
throw err
|
|
393
|
+
}
|
|
394
|
+
// r.status === "unavailable" → 通常経路 (thread/resume) へフォールスルー。
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
335
398
|
const client = this._clientFactory({
|
|
336
399
|
cwd,
|
|
337
400
|
codexCmd: this.codexCmd,
|
|
@@ -343,14 +406,52 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
343
406
|
// unhandled rejection を作らず、複数の reconnect が同じ promise を安全に await できる)。
|
|
344
407
|
let settleReady
|
|
345
408
|
const session = {
|
|
409
|
+
// primary stream_id: legacy な単一値 emit / ログ用。案B では最新購読端末へ寄せる
|
|
410
|
+
// (streamIds が真の購読者集合)。app-server 通知や emit は都度 session.stream_id を読む。
|
|
346
411
|
stream_id,
|
|
347
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()]]),
|
|
348
422
|
// #1 in-memory reattach のキー。attach 試行を跨いで安定 (WS 再接続で stream_id は変わる)。
|
|
349
423
|
attachId: attachId || null,
|
|
350
424
|
client,
|
|
351
425
|
threadId: null,
|
|
426
|
+
// T06541 major#3: cwd reattach の in-flight 引き当てキー。thread/resume 完了まで threadId は
|
|
427
|
+
// null なので、並行 cold attach が「同一 (cwd, resumeThreadId)」を検出できるよう resume 対象の
|
|
428
|
+
// thread id を **client 生成前 (同期)** に持たせる。これで後続 attach は _findLiveSessionByCwd で
|
|
429
|
+
// この session を引き当て _tryReattach の session.ready 合流に入り、二重 app-server を作らない。
|
|
430
|
+
resumeThreadId: resumeThreadId || null,
|
|
352
431
|
activeTurnId: null,
|
|
353
|
-
|
|
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,
|
|
452
|
+
// request_id -> { turnId, method, params }。major#1: reattach で新 stream へ未応答の承認要求を
|
|
453
|
+
// 再送できるよう、元の method/params も保持する (Claude の _permissionResolvers と同型)。
|
|
454
|
+
pendingApprovals: new Map(),
|
|
354
455
|
// ブラウザから codex.detach を受け取った (= 画面の持ち主が居なくなった) か。診断・ログ用。
|
|
355
456
|
// Stage 5: 回収の可否は detachRequested ではなく lastActivityMs(LRU)と activeTurnId
|
|
356
457
|
// (busy/stuck)で決める。detach してもすぐには殺さず warm 保持する。
|
|
@@ -368,6 +469,15 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
368
469
|
// attach 時刻=新規セッションは「たった今作った」で上位に来る(閲覧中の古い会話に負けない)。
|
|
369
470
|
lastTurnAtMs: Date.now(),
|
|
370
471
|
ready: null,
|
|
472
|
+
// T06541 案1(高忠実度履歴の蓄積): cold attach 時の flatten 済み ThreadItem 履歴を
|
|
473
|
+
// 保持し、ライブ item/completed ごとに追記する。cwd reattach(新プロセスを立てない
|
|
474
|
+
// 復帰)時にこれを返すことで、frontend の remount 白紙化を防ぎつつ、rollout JSONL の
|
|
475
|
+
// 生データ(ツールカードが粗い)ではなく app-server 整形済みの高忠実度履歴を維持する。
|
|
476
|
+
historyItems: [],
|
|
477
|
+
historyTruncated: false,
|
|
478
|
+
// major#2: ライブ追記でも cold flatten と同じ「item 数+概算 byte」二重上限を課すため、
|
|
479
|
+
// 蓄積済み履歴の概算 byte 合計を保持する (再計算を避け O(1) で trim 判定)。
|
|
480
|
+
historyBytes: 0,
|
|
371
481
|
}
|
|
372
482
|
session.ready = new Promise((resolve) => {
|
|
373
483
|
settleReady = resolve
|
|
@@ -398,6 +508,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
398
508
|
: { items: [], truncated: false, total: 0 }
|
|
399
509
|
const history = flat.items
|
|
400
510
|
const historyTruncated = flat.truncated
|
|
511
|
+
// T06541 案1: cold attach で得た高忠実度履歴をセッションに保持する。以降ライブ item
|
|
512
|
+
// 完了ごとに追記し、cwd reattach 時にこれを返す(新プロセス thread/resume を再実行しない)。
|
|
513
|
+
// major#2: byte 予算判定を O(1) にするため概算 byte 合計も持つ (flatten は既に byte 上限適用済み)。
|
|
514
|
+
session.historyItems = history.slice()
|
|
515
|
+
session.historyBytes = history.reduce((n, it) => n + estimateItemBytes(it), 0)
|
|
516
|
+
session.historyTruncated = historyTruncated
|
|
401
517
|
// 案C: 権威的なターン状態を resume 応答から取り出してフロントへ渡す。
|
|
402
518
|
// これによりフロントは会話末尾からの推測をやめ、Thread.status で turnActive を
|
|
403
519
|
// 決められる (生成中スピナー固着バグの根治)。新規スレッドは常に idle 相当。
|
|
@@ -461,39 +577,105 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
461
577
|
return null
|
|
462
578
|
}
|
|
463
579
|
|
|
580
|
+
/**
|
|
581
|
+
* T06541: 同一 (cwd, threadId) のライブ session を返す (cwd ベース in-memory reattach 用)。
|
|
582
|
+
* frontend は attach_id を送らないため attachId 経路は発火せず、resume は毎回 thread/resume で
|
|
583
|
+
* 新プロセスを起動していた(別プロセスで生成中のターンを Thread.status で観測できず turn_active
|
|
584
|
+
* が false になる症状⑦の主機序)。resume_thread_id 一致で生きた session を引き当て所有権を付け替え
|
|
585
|
+
* れば、生成中ターン・停止・完了通知・高忠実度履歴が 1 本に揃う。
|
|
586
|
+
* ⚠️ **同一会話 (threadId 一致) のみ** を対象にし、別会話 (threadId 不一致) を誤って奪わない。
|
|
587
|
+
* major#3: thread/resume 完了前 (threadId=null) の in-flight session も、resume 対象 thread が
|
|
588
|
+
* 一致すれば引き当てる。これで並行 cold attach の後続が _tryReattach の session.ready 合流に入り、
|
|
589
|
+
* 同一 thread の app-server が二重生成されるのを防ぐ (Claude の同期索引登録と同じ「空の窓」対策)。
|
|
590
|
+
*/
|
|
591
|
+
_findLiveSessionByCwd(cwd, threadId) {
|
|
592
|
+
if (!cwd || !threadId) return null
|
|
593
|
+
for (const s of this.sessions.values()) {
|
|
594
|
+
if (s.dead || s.cwd !== cwd) continue
|
|
595
|
+
// 確定済み thread 一致、または in-flight (未確定) で resume 対象が一致。
|
|
596
|
+
if (s.threadId === threadId) return s
|
|
597
|
+
if (s.threadId === null && s.resumeThreadId === threadId) return s
|
|
598
|
+
}
|
|
599
|
+
return null
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* T06541 案1: 完了 ThreadItem を高忠実度履歴 (session.historyItems) に追記する。
|
|
604
|
+
* 表示用バブル時刻 at_ms は completedAtMs (ms epoch) を採る(flattenThreadItems と同じ ms 単位。
|
|
605
|
+
* userMessage はライブでも completedAtMs=ターン冒頭で届くため整合する)。長い会話で無限成長させ
|
|
606
|
+
* ないよう cold flatten と同じ **item 数 (maxItems) + 概算 byte 数 (maxBytes) の二重上限** を課し、
|
|
607
|
+
* 超過分は先頭 (最古) から捨て truncated を立てる (major#2: 巨大ツール出力で reattach payload が
|
|
608
|
+
* 肥大化し T06499 で防いだメモリ増・WS 転送・ブラウザフリーズが再発するのを防ぐ)。
|
|
609
|
+
*/
|
|
610
|
+
_appendHistoryItem(session, item, completedAtMs) {
|
|
611
|
+
if (!session || !item || typeof item !== "object") return
|
|
612
|
+
if (!Array.isArray(session.historyItems)) session.historyItems = []
|
|
613
|
+
if (typeof session.historyBytes !== "number") session.historyBytes = 0
|
|
614
|
+
const entry = typeof completedAtMs === "number" ? { ...item, at_ms: completedAtMs } : item
|
|
615
|
+
session.historyItems.push(entry)
|
|
616
|
+
session.historyBytes += estimateItemBytes(entry)
|
|
617
|
+
const maxItems = this._historyLimits.maxItems
|
|
618
|
+
const maxBytes = this._historyLimits.maxBytes
|
|
619
|
+
// 末尾 1 件は必ず残す (flatten と同じ方針)。item 数超過 or byte 超過の間、先頭から捨てる。
|
|
620
|
+
while (
|
|
621
|
+
session.historyItems.length > 1 &&
|
|
622
|
+
((typeof maxItems === "number" && session.historyItems.length > maxItems) ||
|
|
623
|
+
(typeof maxBytes === "number" && session.historyBytes > maxBytes))
|
|
624
|
+
) {
|
|
625
|
+
const dropped = session.historyItems.shift()
|
|
626
|
+
session.historyBytes -= estimateItemBytes(dropped)
|
|
627
|
+
if (session.historyBytes < 0) session.historyBytes = 0
|
|
628
|
+
session.historyTruncated = true
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
464
632
|
/**
|
|
465
633
|
* 既存ライブ session を新 stream_id へ付け替える (in-memory reattach)。thread が未確定
|
|
466
634
|
* (thread/start in-flight) なら session.ready を await する (成功=threadId / 失敗=null)。
|
|
467
635
|
* 付け替えられなければ null を返し、呼び出し側 attach() は通常経路へフォールスルーする。
|
|
468
636
|
*/
|
|
469
637
|
async _tryReattach(session, newStreamId) {
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
638
|
+
// 案B (T06542): reattach は「所有権付け替え」ではなく **購読者追加**。旧 stream を Map から
|
|
639
|
+
// 消さず、newStreamId を streamIds へ足して両方が同一 live session を購読する状態を作る
|
|
640
|
+
// (どの端末の input/permission/interrupt も解決でき、backend が thread group へ fan-out する)。
|
|
641
|
+
// これにより「2 本目の app-server を立てず、旧端末も生かしたまま」新端末を相乗りさせる。
|
|
474
642
|
// 戻り値 status:
|
|
475
|
-
// "reattached" -
|
|
476
|
-
// "superseded" -
|
|
477
|
-
//
|
|
643
|
+
// "reattached" - newStreamId を購読者として追加した (info 同梱)。
|
|
644
|
+
// "superseded" - newStreamId が既に別 session を保持している異常時のみ (通常は起きない。
|
|
645
|
+
// attach() が :318 で has(stream_id) を弾くため)。呼び出し側は通常経路に
|
|
646
|
+
// 落とさず失敗させる (二重 thread を作らない)。
|
|
478
647
|
// "unavailable"- session が死んだ / first の thread 確定に失敗 (ライブ session 無し)。
|
|
479
648
|
// 呼び出し側は通常経路へフォールスルーしてよい。
|
|
480
|
-
const originalStreamId = session.stream_id
|
|
481
649
|
let threadId = session.threadId
|
|
482
650
|
if (!threadId && session.ready) threadId = await session.ready
|
|
483
651
|
// first の thread op が失敗 / session が死んだ = ライブ session はもう無い。
|
|
484
652
|
if (!threadId || session.dead) return { status: "unavailable" }
|
|
485
|
-
|
|
486
|
-
if (
|
|
487
|
-
|
|
488
|
-
|
|
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)
|
|
489
661
|
session.stream_id = newStreamId
|
|
490
662
|
session.detachRequested = false
|
|
491
|
-
session.lastActivityMs =
|
|
492
|
-
this.sessions.set(newStreamId, session)
|
|
663
|
+
session.lastActivityMs = now
|
|
493
664
|
this.logger?.info?.(
|
|
494
|
-
{
|
|
495
|
-
|
|
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)",
|
|
496
673
|
)
|
|
674
|
+
// major#1: 新 stream へ未応答の承認要求を再送する (Claude の _replayPendingPermissions と同型)。
|
|
675
|
+
// 承認要求は app-server との in-flight callback で、履歴 hydrate には含まれない。旧 stream 宛に
|
|
676
|
+
// 出したカードは remount/端末切替で失われ、応答が来ないとターンが永久 pending になる。新 stream へ
|
|
677
|
+
// 再 emit してカードを復元させる (frontend は request_id で重複排除するので二重化しない)。
|
|
678
|
+
this._replayPendingApprovals(session)
|
|
497
679
|
// 履歴は返さない: reconnect では frontend が表示を保持している (resetDisplay=false) ため
|
|
498
680
|
// 再 hydrate 不要。turnActive は session が保持する activeTurnId から権威的に決める。
|
|
499
681
|
return {
|
|
@@ -602,9 +784,38 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
602
784
|
})
|
|
603
785
|
}
|
|
604
786
|
|
|
605
|
-
/**
|
|
606
|
-
|
|
607
|
-
|
|
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)
|
|
608
819
|
}
|
|
609
820
|
|
|
610
821
|
_onNotification(session, msg) {
|
|
@@ -616,16 +827,28 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
616
827
|
switch (msg.method) {
|
|
617
828
|
case "turn/started":
|
|
618
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)
|
|
619
834
|
// 会話ターン境界で「新しさ」を更新(閲覧 keepalive では更新しない)。
|
|
620
835
|
session.lastTurnAtMs = Date.now()
|
|
621
836
|
break
|
|
622
837
|
case "turn/completed":
|
|
623
838
|
session.activeTurnId = null
|
|
839
|
+
// round3 major#2: turn/started を取りこぼしたまま turn/completed が来た異常時も barrier が
|
|
840
|
+
// 固着しないよう解除する (pump が次 item を fresh に turn/start できる)。
|
|
841
|
+
this._settleTurnActiveWaiter(session)
|
|
624
842
|
session.lastTurnAtMs = Date.now()
|
|
625
843
|
this._autoResolveStalePendingApprovals(session, msg.params?.turn?.id)
|
|
626
844
|
// Stage 5: turn 完了後の reap 予約は不要。以降 idle になれば周期 reconcile が
|
|
627
845
|
// 長 idle TTL / LRU で扱う(detach 済みでも 7 日 warm 保持)。
|
|
628
846
|
break
|
|
847
|
+
case "item/completed":
|
|
848
|
+
// T06541 案1: 完了した ThreadItem を高忠実度履歴に追記する(cwd reattach で返す用)。
|
|
849
|
+
// ライブ表示は下の emit がそのまま担う(本 case は履歴蓄積を足すだけで emit は変えない)。
|
|
850
|
+
this._appendHistoryItem(session, msg.params?.item, msg.params?.completedAtMs)
|
|
851
|
+
break
|
|
629
852
|
default:
|
|
630
853
|
break
|
|
631
854
|
}
|
|
@@ -639,7 +862,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
639
862
|
|
|
640
863
|
_onServerRequest(session, msg) {
|
|
641
864
|
const { stream_id } = session
|
|
642
|
-
|
|
865
|
+
// major#1: reattach 時に新 stream へ再送できるよう method/params も保持する。
|
|
866
|
+
session.pendingApprovals.set(msg.id, {
|
|
867
|
+
turnId: session.activeTurnId,
|
|
868
|
+
method: msg.method,
|
|
869
|
+
params: msg.params,
|
|
870
|
+
})
|
|
643
871
|
this.emit("permission", {
|
|
644
872
|
stream_id,
|
|
645
873
|
thread_id: session.threadId,
|
|
@@ -650,6 +878,25 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
650
878
|
})
|
|
651
879
|
}
|
|
652
880
|
|
|
881
|
+
/**
|
|
882
|
+
* major#1: 未応答の承認要求を現在の stream へ再送する (reattach から呼ぶ)。Claude の
|
|
883
|
+
* _replayPendingPermissions と同型。frontend は request_id で重複排除するため、既に表示済みの
|
|
884
|
+
* 端末でも二重化しない。method/params は _onServerRequest 受信時に保存済み。
|
|
885
|
+
*/
|
|
886
|
+
_replayPendingApprovals(session) {
|
|
887
|
+
if (!session || session.pendingApprovals.size === 0) return
|
|
888
|
+
for (const [request_id, info] of session.pendingApprovals) {
|
|
889
|
+
this.emit("permission", {
|
|
890
|
+
stream_id: session.stream_id,
|
|
891
|
+
thread_id: session.threadId,
|
|
892
|
+
cwd: session.cwd,
|
|
893
|
+
request_id,
|
|
894
|
+
method: info.method,
|
|
895
|
+
params: info.params,
|
|
896
|
+
})
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
653
900
|
// ターン完了時、そのターンに紐づく未解決の承認要求は自動 decline する
|
|
654
901
|
// (browser が応答する前にターンが打ち切られた/クラッシュ相当のケース。
|
|
655
902
|
// claude-stream-bridge.mjs の「ターン終了時に未解決分は自動 deny」と同じ設計)。
|
|
@@ -664,7 +911,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
664
911
|
_onClientExit(session, info) {
|
|
665
912
|
if (session.dead) return
|
|
666
913
|
session.dead = true
|
|
667
|
-
|
|
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)
|
|
668
920
|
// app-server の終了は必ず記録する。ターン実行中の exit は「rollout に output 欠落の
|
|
669
921
|
// custom_tool_call を残した可能性がある」= thread 汚染の予兆なので warn で出す
|
|
670
922
|
// (codex_core の `Custom tool call output is missing for call id: ...` の発生源)。
|
|
@@ -715,55 +967,177 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
715
967
|
const session = this._require(stream_id)
|
|
716
968
|
// Stage 5: _touch で lastActivityMs を更新 = LRU 先頭へ。ターン開始で activeTurnId が
|
|
717
969
|
// 立ち、reconcile は healthy busy を protected 扱いにするので実行中に kill されない。
|
|
718
|
-
|
|
970
|
+
// 案B: stream_id を渡し端末別活性も更新(死端末 GC が入力継続端末を掃除しない)。
|
|
971
|
+
this._touch(session, stream_id)
|
|
719
972
|
const input = userInputFromMessage(message)
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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" })
|
|
741
998
|
return
|
|
742
|
-
} catch (retryErr) {
|
|
743
|
-
// 呼び出し元 (main.mjs の codex.input dispatch) が既存の .catch() で
|
|
744
|
-
// codex.error を送出する経路に reason を乗せて届ける (bridge 側で二重に
|
|
745
|
-
// 'error' イベントを emit すると frontend へ codex.error が二重送出される
|
|
746
|
-
// ため、ここでは throw のみに留める)。
|
|
747
|
-
const reason = "turn_mismatch_retry_failed"
|
|
748
|
-
throw Object.assign(
|
|
749
|
-
new Error(
|
|
750
|
-
`turn/steer failed (${err.message}) and turn/start retry also failed: ${retryErr.message}`,
|
|
751
|
-
),
|
|
752
|
-
{ reason, cause: retryErr },
|
|
753
|
-
)
|
|
754
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)
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
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)
|
|
755
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
|
+
)
|
|
756
1134
|
}
|
|
757
|
-
const params = { threadId: session.threadId, input }
|
|
758
|
-
if (model) params.model = model
|
|
759
|
-
if (effort) params.effort = effort
|
|
760
|
-
await session.client.request("turn/start", params)
|
|
761
1135
|
}
|
|
762
1136
|
|
|
763
1137
|
/** @returns {Promise<void>} */
|
|
764
1138
|
async interrupt({ stream_id }) {
|
|
765
1139
|
const session = this._require(stream_id)
|
|
766
|
-
this._touch(session)
|
|
1140
|
+
this._touch(session, stream_id)
|
|
767
1141
|
if (!session.activeTurnId) return
|
|
768
1142
|
await session.client.request("turn/interrupt", {
|
|
769
1143
|
threadId: session.threadId,
|
|
@@ -774,7 +1148,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
774
1148
|
/** サーバー発の承認/入力要求への応答。result の shape は method ごとに呼び出し側が組み立てる。 */
|
|
775
1149
|
permissionReply({ stream_id, request_id, result }) {
|
|
776
1150
|
const session = this._require(stream_id)
|
|
777
|
-
|
|
1151
|
+
// 案B エッジ②: 先着 1 応答で解決。stream_id を渡し応答端末の活性を更新(死端末 GC 対象外に)。
|
|
1152
|
+
this._touch(session, stream_id)
|
|
778
1153
|
if (!session.pendingApprovals.has(request_id)) {
|
|
779
1154
|
this.logger?.warn?.({ stream_id, request_id }, "codex permissionReply: unknown request_id (ignored)")
|
|
780
1155
|
return
|
|
@@ -795,18 +1170,48 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
795
1170
|
keepalive({ stream_id }) {
|
|
796
1171
|
const session = this.sessions.get(stream_id)
|
|
797
1172
|
if (!session || session.dead) return false
|
|
798
|
-
this._touch(session)
|
|
1173
|
+
this._touch(session, stream_id)
|
|
799
1174
|
return true
|
|
800
1175
|
}
|
|
801
1176
|
|
|
802
1177
|
/**
|
|
803
|
-
* グレースフル切断(Stage 5)。**すぐには reap しない**。
|
|
804
|
-
*
|
|
805
|
-
*
|
|
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 タブ閉じても残りの端末のライブ表示が切れない。
|
|
806
1185
|
*/
|
|
807
1186
|
detach({ stream_id }) {
|
|
808
1187
|
const session = this.sessions.get(stream_id)
|
|
809
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 を判断する。
|
|
810
1215
|
session.detachRequested = true
|
|
811
1216
|
this.logger?.info?.(
|
|
812
1217
|
{
|
|
@@ -815,10 +1220,55 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
815
1220
|
cwd: session.cwd,
|
|
816
1221
|
busy: !!session.activeTurnId,
|
|
817
1222
|
},
|
|
818
|
-
"codex detach requested by browser (kept warm; reconcile handles reap)",
|
|
1223
|
+
"codex detach requested by browser (last terminal; kept warm; reconcile handles reap)",
|
|
819
1224
|
)
|
|
820
1225
|
}
|
|
821
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
|
+
|
|
822
1272
|
/**
|
|
823
1273
|
* 診断用: 生きているセッションの一覧 (sibling-inject の失敗理由に載せる / ログ用)。
|
|
824
1274
|
* 「TUI codex は hub-agent の管理外なので ask-sibling の宛先にならない」ことを
|
|
@@ -832,8 +1282,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
832
1282
|
describeLiveSessions(cwd) {
|
|
833
1283
|
const now = Date.now()
|
|
834
1284
|
const out = []
|
|
835
|
-
|
|
836
|
-
|
|
1285
|
+
// 案B: sessions は多対一なので distinct session で走査する(購読端末数ぶん重複させない)。
|
|
1286
|
+
for (const session of this._liveSessions()) {
|
|
837
1287
|
if (cwd && session.cwd !== cwd) continue
|
|
838
1288
|
out.push({
|
|
839
1289
|
kind: "codex",
|
|
@@ -888,8 +1338,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
888
1338
|
if (!cwd || !text) return false
|
|
889
1339
|
let best = null
|
|
890
1340
|
let bestAt = -1
|
|
891
|
-
|
|
892
|
-
|
|
1341
|
+
// 案B: distinct session で走査(多対一 Map の重複を除く)。
|
|
1342
|
+
for (const session of this._liveSessions()) {
|
|
1343
|
+
if (session.cwd !== cwd) continue
|
|
893
1344
|
// 宛先選定の「最新」は会話ターン境界時刻で判定する(閲覧 keepalive で動く
|
|
894
1345
|
// lastActivityMs を使うと「見ているだけの古い会話」が本命を追い越す)。
|
|
895
1346
|
const at = session.lastTurnAtMs ?? session.lastActivityMs ?? 0
|
|
@@ -963,9 +1414,11 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
963
1414
|
if (!cwd || !id || !text) return { status: "not_found" }
|
|
964
1415
|
let bySid = null
|
|
965
1416
|
const byThread = []
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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)) {
|
|
969
1422
|
bySid = session
|
|
970
1423
|
break // stream_id は一意なので即確定
|
|
971
1424
|
}
|
|
@@ -1023,7 +1476,11 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1023
1476
|
_reapSession(session, now, reason) {
|
|
1024
1477
|
if (session.dead) return
|
|
1025
1478
|
session.dead = true
|
|
1026
|
-
|
|
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)
|
|
1027
1484
|
try {
|
|
1028
1485
|
session.client.stop()
|
|
1029
1486
|
} catch (err) {
|
|
@@ -1036,7 +1493,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1036
1493
|
cwd: session.cwd,
|
|
1037
1494
|
reason,
|
|
1038
1495
|
idle_ms: now - (session.lastActivityMs ?? now),
|
|
1039
|
-
live_after: this.
|
|
1496
|
+
live_after: this._liveSessions().length,
|
|
1040
1497
|
},
|
|
1041
1498
|
"codex session reaped",
|
|
1042
1499
|
)
|
|
@@ -1057,7 +1514,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1057
1514
|
* @returns {number} 退避した数
|
|
1058
1515
|
*/
|
|
1059
1516
|
_evictWarmPoolOverflow(now, reason = "lru-evict") {
|
|
1060
|
-
|
|
1517
|
+
// 案B: warm pool 上限は「distinct な生存 session 数」で判定する(購読端末数ではない)。
|
|
1518
|
+
const live = this._liveSessions()
|
|
1061
1519
|
const overflow = live.length - this._warmPoolMax
|
|
1062
1520
|
if (overflow <= 0) return 0
|
|
1063
1521
|
const evictable = live
|
|
@@ -1098,9 +1556,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1098
1556
|
reconcileSessions() {
|
|
1099
1557
|
const now = Date.now()
|
|
1100
1558
|
let reaped = 0
|
|
1559
|
+
// 案B: 0. 死端末 GC を先に回す(多端末購読で応答が途絶えた端末を購読集合から掃除)。これで
|
|
1560
|
+
// 「全端末が死んで空になったセッション」が下の idle TTL 経路で正しく reap 対象になる。
|
|
1561
|
+
this.gcDeadTerminals()
|
|
1101
1562
|
// 1〜2: stuck turn / long idle。protected(healthy running turn)はスキップ。
|
|
1102
|
-
|
|
1103
|
-
|
|
1563
|
+
// 案B: distinct session で走査(多対一 Map の重複 reap を避ける)。
|
|
1564
|
+
for (const session of this._liveSessions()) {
|
|
1104
1565
|
if (this._isProtected(session, now)) continue
|
|
1105
1566
|
const idle = now - (session.lastActivityMs ?? now)
|
|
1106
1567
|
if (session.activeTurnId && idle > this._stuckTurnTtlMs) {
|
|
@@ -1123,8 +1584,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1123
1584
|
|
|
1124
1585
|
/** 全セッションを即座に終了する (agent shutdown 用。ターン完走は待たない)。 */
|
|
1125
1586
|
shutdown() {
|
|
1126
|
-
|
|
1587
|
+
// 案B: distinct session で走査(多対一 Map の重複 stop を避ける)。
|
|
1588
|
+
for (const session of this._liveSessions()) {
|
|
1127
1589
|
session.dead = true
|
|
1590
|
+
// round3: shutdown で barrier を解除し queue 入力を reject(promise 宙吊り防止)。
|
|
1591
|
+
this._settleTurnActiveWaiter(session)
|
|
1592
|
+
this._drainRejectQueue(session, { reason: "queue_dead" })
|
|
1128
1593
|
// hub-agent 再起動時にターン実行中のセッションを落とすと thread が汚染されうる
|
|
1129
1594
|
// (exec 中の kill → output 欠落)。因果を後から辿れるよう必ず記録する。
|
|
1130
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
|