@cocorograph/hub-agent 0.7.72 → 0.7.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/codex-stream-bridge.mjs +224 -182
- package/src/main.mjs +16 -1
package/package.json
CHANGED
|
@@ -61,47 +61,100 @@ function siblingEnvFor(cwd) {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
64
|
+
// ── Stage 5 (2026-07-19): bounded warm pool + 長 idle TTL への再設計 ───────────
|
|
65
|
+
// 旧設計: detach → **5 分** の per-session タイマーで reap(`_maybeReapAfterIdle`)+
|
|
66
|
+
// 1h の無活性 reaper(`gcAbandonedSessions`)。これは codex-review のように 1 ターンが
|
|
67
|
+
// 数分〜数十分かかり、かつ **別 CLI 画面(claude チャット)から呼ぶため codex 画面が非
|
|
68
|
+
// アクティブ = detach 済み**になる用途で、「ターン完了後 5 分の idle で reaped → 次の依頼が
|
|
69
|
+
// 届かない」構造的ミスマッチを生んでいた(2026-07-19 調査)。
|
|
70
|
+
//
|
|
71
|
+
// 新設計: **周期 reconcile 一本化**(`reconcileSessions`、state loop から ~5s 毎)。
|
|
72
|
+
// - detach してもすぐ殺さず、**長 idle TTL(既定 7 日、claude softDetach と同思想)** で
|
|
73
|
+
// warm 保持する。
|
|
74
|
+
// - 際限ないプロセス蓄積は **LRU 件数上限(既定 5、マシン全体)** で防ぐ。6 個目が来たら
|
|
75
|
+
// 最も使っていない **非 protected** セッションを 1 個 reap。旧「5 分で殺す」の代わりに
|
|
76
|
+
// 「上限で天井を作る」ことで、TTL を長くしてもリークしない(T06500 の churn 削減も前提)。
|
|
77
|
+
// - **busy(healthy running turn)は絶対に reap しない**(TTL でも LRU でも)。
|
|
78
|
+
// - **stuck turn**(activeTurnId 固着だが app-server 通知が STUCK_TTL 途絶えた=ハング)は
|
|
79
|
+
// protected から外れ、別途 reap 対象(プロセスリーク・固着の救済)。
|
|
80
|
+
// これにより意味論は「**閲覧中=無期限(keepalive で LRU 先頭維持)/ 直近 5 セッションは
|
|
81
|
+
// detach しても 7 日 warm / 上限超過は LRU で 1 個 reap / busy は不可侵 / stuck は救済**」。
|
|
82
|
+
|
|
83
|
+
// warm 保持の idle TTL(非 busy がこの時間 無活性なら reap の対象。上限内でも 7 日で頭打ち)。
|
|
84
|
+
const IDLE_DETACH_TTL_MS =
|
|
85
|
+
Number(process.env.HUB_AGENT_CODEX_IDLE_DETACH_TTL_MS) || 7 * 24 * 60 * 60 * 1000
|
|
86
|
+
|
|
87
|
+
// LRU warm pool の上限(マシン全体で同時に生かす codex セッション数)。超過分は最も使って
|
|
88
|
+
// いない非 protected セッションから reap。1 セッション ≈ 120-140MB(node ラッパー + 本体 +
|
|
89
|
+
// MCP 子)なので既定 5(~650MB)。env で調整可。
|
|
90
|
+
const WARM_POOL_MAX = Number(process.env.HUB_AGENT_CODEX_WARM_POOL_MAX) || 5
|
|
91
|
+
|
|
92
|
+
// stuck turn 検出の閾値。activeTurnId が立っているのに app-server 通知がこの時間途絶えた
|
|
93
|
+
// セッションは「ハングしたターン」とみなし protected から外して reap する(healthy な長ターンは
|
|
94
|
+
// reasoning/output delta で都度 touch されるため該当しない)。旧 `HUB_AGENT_DEAD_TERMINAL_TTL_MS`
|
|
95
|
+
// を後方互換で受ける。既定 1h。
|
|
96
|
+
const STUCK_TURN_TTL_MS =
|
|
97
|
+
Number(process.env.HUB_AGENT_CODEX_STUCK_TURN_TTL_MS) ||
|
|
98
|
+
Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) ||
|
|
99
|
+
60 * 60 * 1000
|
|
93
100
|
|
|
94
101
|
const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
|
|
95
102
|
|
|
103
|
+
// T06499 (2026-07-18): 巨大スレッドの resume ガード。長期運用したスレッドの rollout は
|
|
104
|
+
// 20MB 超まで肥大し得る (実測: メイン repo 540KB が正常なのに対し 20MB のスレッドあり)。
|
|
105
|
+
// thread/resume 応答をそのまま平坦化して browser へ送ると、
|
|
106
|
+
// ① WS へ 20MB の JSON を積む → 転送で数秒フリーズ
|
|
107
|
+
// ② browser 側で全 item を codexHistoryToEvents → reducer merge → 数千バブル描画で
|
|
108
|
+
// メインスレッドがハング (履歴も、その裏で撃つ codex.sessions.request 由来の
|
|
109
|
+
// セッション切替バーも一切出ない = UX の断崖)
|
|
110
|
+
// が起きる。thread/resume 自体はモデル側の会話コンテキスト復元のため全量が必要だが、
|
|
111
|
+
// **browser へ渡す表示用履歴は末尾 N ターン分に絞る** ことで断崖を回避する。上限は
|
|
112
|
+
// 「item 数」と「概算バイト数」の 2 軸で、末尾から詰めてどちらか先に達したら truncated。
|
|
113
|
+
const HISTORY_MAX_ITEMS = Number(process.env.HUB_AGENT_CODEX_HISTORY_MAX_ITEMS) || 200
|
|
114
|
+
const HISTORY_MAX_BYTES =
|
|
115
|
+
Number(process.env.HUB_AGENT_CODEX_HISTORY_MAX_BYTES) || 1.5 * 1024 * 1024
|
|
116
|
+
|
|
96
117
|
// Slice 6: thread/resume が返す Thread.turns[].items[] を単一の時系列配列へ平坦化する。
|
|
97
118
|
// turns は開始ターン順の配列、各 turn.items も時系列順のため単純結合で会話順を保てる。
|
|
98
|
-
|
|
119
|
+
//
|
|
120
|
+
// T06499: 末尾から maxItems / maxBytes の budget 内に収め、超過分は落として truncated=true を
|
|
121
|
+
// 返す。末尾優先なのは「直近の会話が最も価値が高く、古い履歴は resume されたモデル側が保持して
|
|
122
|
+
// いる」ため。budget を末尾から積むので、20MB スレッドでも全 item を stringify せず早期に打ち切る
|
|
123
|
+
// (kept が budget に達した時点で残りの巨大な古い item には触れない)。
|
|
124
|
+
// @returns {{items: object[], truncated: boolean, total: number}}
|
|
125
|
+
function flattenThreadItems(thread, { maxItems = HISTORY_MAX_ITEMS, maxBytes = HISTORY_MAX_BYTES } = {}) {
|
|
99
126
|
const turns = Array.isArray(thread?.turns) ? thread.turns : []
|
|
100
|
-
const
|
|
127
|
+
const all = []
|
|
101
128
|
for (const turn of turns) {
|
|
102
|
-
if (Array.isArray(turn?.items))
|
|
129
|
+
if (Array.isArray(turn?.items)) all.push(...turn.items)
|
|
130
|
+
}
|
|
131
|
+
const total = all.length
|
|
132
|
+
const kept = []
|
|
133
|
+
let bytes = 0
|
|
134
|
+
let truncated = false
|
|
135
|
+
// 末尾 (最新) から詰める。budget 超過で打ち切り、truncated を立てる。
|
|
136
|
+
for (let i = all.length - 1; i >= 0; i--) {
|
|
137
|
+
if (kept.length >= maxItems) {
|
|
138
|
+
truncated = true
|
|
139
|
+
break
|
|
140
|
+
}
|
|
141
|
+
const item = all[i]
|
|
142
|
+
let size = 0
|
|
143
|
+
try {
|
|
144
|
+
size = JSON.stringify(item)?.length || 0
|
|
145
|
+
} catch {
|
|
146
|
+
size = 0
|
|
147
|
+
}
|
|
148
|
+
// 最低 1 件は必ず入れる (単一 item が maxBytes を超えても末尾 1 件は見せる)。
|
|
149
|
+
if (kept.length > 0 && bytes + size > maxBytes) {
|
|
150
|
+
truncated = true
|
|
151
|
+
break
|
|
152
|
+
}
|
|
153
|
+
kept.push(item)
|
|
154
|
+
bytes += size
|
|
103
155
|
}
|
|
104
|
-
|
|
156
|
+
kept.reverse() // 末尾から積んだので時系列順へ戻す。
|
|
157
|
+
return { items: kept, truncated, total }
|
|
105
158
|
}
|
|
106
159
|
|
|
107
160
|
/**
|
|
@@ -184,15 +237,34 @@ function userInputFromMessage(message) {
|
|
|
184
237
|
}
|
|
185
238
|
|
|
186
239
|
export class CodexStreamBridge extends EventEmitter {
|
|
187
|
-
constructor({
|
|
240
|
+
constructor({
|
|
241
|
+
codexCmd,
|
|
242
|
+
logger,
|
|
243
|
+
clientFactory,
|
|
244
|
+
detachTtlMs,
|
|
245
|
+
warmPoolMax,
|
|
246
|
+
stuckTurnTtlMs,
|
|
247
|
+
rolloutPresence,
|
|
248
|
+
historyMaxItems,
|
|
249
|
+
historyMaxBytes,
|
|
250
|
+
} = {}) {
|
|
188
251
|
super()
|
|
189
252
|
this.codexCmd = codexCmd
|
|
190
253
|
this.logger = logger
|
|
191
254
|
this._clientFactory = clientFactory || ((opts) => new CodexAppServerClient(opts))
|
|
192
|
-
//
|
|
255
|
+
// Stage 5: warm 保持の idle TTL(既定 7 日)。テストから短縮できるよう注入可能。
|
|
193
256
|
this._detachTtlMs = detachTtlMs ?? IDLE_DETACH_TTL_MS
|
|
257
|
+
// Stage 5: LRU warm pool 上限(既定 5)。テストから小さい値を注入可能。
|
|
258
|
+
this._warmPoolMax = warmPoolMax ?? WARM_POOL_MAX
|
|
259
|
+
// Stage 5: stuck turn 検出 TTL(既定 1h)。テストから短縮可能。
|
|
260
|
+
this._stuckTurnTtlMs = stuckTurnTtlMs ?? STUCK_TURN_TTL_MS
|
|
194
261
|
// #4 失敗分類の rollout 存在チェック (tri-state。テストから注入可能)。
|
|
195
262
|
this._rolloutPresence = rolloutPresence || codexRolloutPresence
|
|
263
|
+
// T06499: 履歴 hydrate の上限 (テストから小さい値を注入可能)。
|
|
264
|
+
this._historyLimits = {
|
|
265
|
+
maxItems: historyMaxItems ?? HISTORY_MAX_ITEMS,
|
|
266
|
+
maxBytes: historyMaxBytes ?? HISTORY_MAX_BYTES,
|
|
267
|
+
}
|
|
196
268
|
/** @type {Map<string, object>} stream_id -> session record */
|
|
197
269
|
this.sessions = new Map()
|
|
198
270
|
}
|
|
@@ -210,7 +282,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
210
282
|
* なければ、通常の attach (thread/start | thread/resume) にフォールスルーする。
|
|
211
283
|
*
|
|
212
284
|
* @returns {Promise<{resuming: boolean, threadId: string, history: object[],
|
|
213
|
-
* threadStatus: string|null, lastTurnStatus: string|null,
|
|
285
|
+
* historyTruncated: boolean, threadStatus: string|null, lastTurnStatus: string|null,
|
|
286
|
+
* turnActive: boolean}>}
|
|
214
287
|
*/
|
|
215
288
|
async attach({ stream_id, cwd, model, effort, approvalPolicy, sandbox, resumeThreadId, attachId }) {
|
|
216
289
|
if (this.sessions.has(stream_id)) {
|
|
@@ -258,14 +331,15 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
258
331
|
threadId: null,
|
|
259
332
|
activeTurnId: null,
|
|
260
333
|
pendingApprovals: new Map(), // request_id -> { turnId }
|
|
261
|
-
|
|
262
|
-
//
|
|
263
|
-
//
|
|
334
|
+
// ブラウザから codex.detach を受け取った (= 画面の持ち主が居なくなった) か。診断・ログ用。
|
|
335
|
+
// Stage 5: 回収の可否は detachRequested ではなく lastActivityMs(LRU)と activeTurnId
|
|
336
|
+
// (busy/stuck)で決める。detach してもすぐには殺さず warm 保持する。
|
|
264
337
|
// ask-sibling 注入 (sendToCwd) は「新しい持ち主が付いた」とみなして false に戻す。
|
|
265
338
|
detachRequested: false,
|
|
266
339
|
dead: false,
|
|
267
|
-
//
|
|
268
|
-
//
|
|
340
|
+
// Stage 5 reconcile(LRU / idle TTL / stuck)用の最終活性時刻。browser 入力と app-server
|
|
341
|
+
// 通知 (ターン進捗) の両方で更新する。LRU は「最終活性が最も古い非 protected」を退避する。
|
|
342
|
+
// Date.now() は hub-agent 実行環境で利用可。
|
|
269
343
|
lastActivityMs: Date.now(),
|
|
270
344
|
ready: null,
|
|
271
345
|
}
|
|
@@ -292,7 +366,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
292
366
|
session.threadId = result?.thread?.id
|
|
293
367
|
// 新規スレッド (thread/start) は履歴が無いので空配列。resume 時のみ平坦化する
|
|
294
368
|
// (thread/start のレスポンスは turns を持たない仕様のため呼んでも常に空)。
|
|
295
|
-
|
|
369
|
+
// T06499: 巨大スレッドは末尾 N ターンに絞る (truncated=true なら browser がバナー表示)。
|
|
370
|
+
const flat = resuming
|
|
371
|
+
? flattenThreadItems(result?.thread, this._historyLimits)
|
|
372
|
+
: { items: [], truncated: false, total: 0 }
|
|
373
|
+
const history = flat.items
|
|
374
|
+
const historyTruncated = flat.truncated
|
|
296
375
|
// 案C: 権威的なターン状態を resume 応答から取り出してフロントへ渡す。
|
|
297
376
|
// これによりフロントは会話末尾からの推測をやめ、Thread.status で turnActive を
|
|
298
377
|
// 決められる (生成中スピナー固着バグの根治)。新規スレッドは常に idle 相当。
|
|
@@ -317,13 +396,22 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
317
396
|
resuming,
|
|
318
397
|
thread_status: turnState.threadStatus,
|
|
319
398
|
live_for_cwd: this.describeLiveSessions(cwd).length,
|
|
399
|
+
// T06499: 巨大スレッド hydrate の可視化 (何件中何件を返したか)。
|
|
400
|
+
history_items: history.length,
|
|
401
|
+
history_total: flat.total,
|
|
402
|
+
history_truncated: historyTruncated,
|
|
320
403
|
},
|
|
321
404
|
"codex session attached",
|
|
322
405
|
)
|
|
406
|
+
// Stage 5: genuine attach で 1 プロセス増えたので、LRU 上限の超過分を即時退避する
|
|
407
|
+
// (次の reconnect まで ~5s 待たず、6 個目の app-server が居座るのを防ぐ)。新セッションは
|
|
408
|
+
// たった今 touch 済み = LRU 先頭なので退避対象にならず、最も古い非 protected が落ちる。
|
|
409
|
+
this._evictWarmPoolOverflow(Date.now(), "lru-evict-on-attach")
|
|
323
410
|
return {
|
|
324
411
|
resuming,
|
|
325
412
|
threadId: session.threadId,
|
|
326
413
|
history,
|
|
414
|
+
historyTruncated,
|
|
327
415
|
threadStatus: turnState.threadStatus,
|
|
328
416
|
lastTurnStatus: turnState.lastTurnStatus,
|
|
329
417
|
turnActive: turnState.turnActive,
|
|
@@ -374,7 +462,6 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
374
462
|
this.sessions.delete(originalStreamId)
|
|
375
463
|
session.stream_id = newStreamId
|
|
376
464
|
session.detachRequested = false
|
|
377
|
-
this._cancelIdleReap(session, "reattach")
|
|
378
465
|
session.lastActivityMs = Date.now()
|
|
379
466
|
this.sessions.set(newStreamId, session)
|
|
380
467
|
this.logger?.info?.(
|
|
@@ -389,6 +476,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
389
476
|
resuming: true,
|
|
390
477
|
threadId,
|
|
391
478
|
history: [],
|
|
479
|
+
// reconnect の in-memory reattach は表示を保持しているので履歴を返さない = 常に非 truncated。
|
|
480
|
+
historyTruncated: false,
|
|
392
481
|
threadStatus: session.activeTurnId ? "active" : "idle",
|
|
393
482
|
lastTurnStatus: null,
|
|
394
483
|
turnActive: !!session.activeTurnId,
|
|
@@ -501,9 +590,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
501
590
|
case "turn/completed":
|
|
502
591
|
session.activeTurnId = null
|
|
503
592
|
this._autoResolveStalePendingApprovals(session, msg.params?.turn?.id)
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
this._maybeReapAfterIdle(session, "turn-completed-after-detach")
|
|
593
|
+
// Stage 5: turn 完了後の reap 予約は不要。以降 idle になれば周期 reconcile が
|
|
594
|
+
// 長 idle TTL / LRU で扱う(detach 済みでも 7 日 warm 保持)。
|
|
507
595
|
break
|
|
508
596
|
default:
|
|
509
597
|
break
|
|
@@ -543,7 +631,6 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
543
631
|
_onClientExit(session, info) {
|
|
544
632
|
if (session.dead) return
|
|
545
633
|
session.dead = true
|
|
546
|
-
this._clearDetachTimer(session)
|
|
547
634
|
this.sessions.delete(session.stream_id)
|
|
548
635
|
// app-server の終了は必ず記録する。ターン実行中の exit は「rollout に output 欠落の
|
|
549
636
|
// custom_tool_call を残した可能性がある」= thread 汚染の予兆なので warn で出す
|
|
@@ -593,11 +680,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
593
680
|
*/
|
|
594
681
|
async input({ stream_id, message, model, effort }) {
|
|
595
682
|
const session = this._require(stream_id)
|
|
683
|
+
// Stage 5: _touch で lastActivityMs を更新 = LRU 先頭へ。ターン開始で activeTurnId が
|
|
684
|
+
// 立ち、reconcile は healthy busy を protected 扱いにするので実行中に kill されない。
|
|
596
685
|
this._touch(session)
|
|
597
|
-
// TTL 待ちのセッションに新ターンを流す = まだ使われている。予約を解除しないと
|
|
598
|
-
// ターン実行中 (exec 中) に kill され、rollout に output 欠落の custom_tool_call が
|
|
599
|
-
// 残って thread が汚染される (2026-07-17 の事故)。
|
|
600
|
-
this._cancelIdleReap(session, "input")
|
|
601
686
|
const input = userInputFromMessage(message)
|
|
602
687
|
if (session.activeTurnId) {
|
|
603
688
|
try {
|
|
@@ -646,7 +731,6 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
646
731
|
async interrupt({ stream_id }) {
|
|
647
732
|
const session = this._require(stream_id)
|
|
648
733
|
this._touch(session)
|
|
649
|
-
this._cancelIdleReap(session, "interrupt")
|
|
650
734
|
if (!session.activeTurnId) return
|
|
651
735
|
await session.client.request("turn/interrupt", {
|
|
652
736
|
threadId: session.threadId,
|
|
@@ -658,7 +742,6 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
658
742
|
permissionReply({ stream_id, request_id, result }) {
|
|
659
743
|
const session = this._require(stream_id)
|
|
660
744
|
this._touch(session)
|
|
661
|
-
this._cancelIdleReap(session, "permission-reply")
|
|
662
745
|
if (!session.pendingApprovals.has(request_id)) {
|
|
663
746
|
this.logger?.warn?.({ stream_id, request_id }, "codex permissionReply: unknown request_id (ignored)")
|
|
664
747
|
return
|
|
@@ -670,13 +753,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
670
753
|
/**
|
|
671
754
|
* ブラウザ閲覧中の生存シグナル (codex.keepalive)。frontend が Codex チャットを開いて
|
|
672
755
|
* いる間だけ定期送信するため、これが届く = 「人が見ている」の観測可能な証跡になる。
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
* ⚠️ 意図的に `_cancelIdleReap` を **呼ばない**。detach 済みセッションの 5 分 TTL は
|
|
677
|
-
* 「ブラウザが去った」という別事実に基づく予約であり、detach と in-flight keepalive が
|
|
678
|
-
* 競合しても予約を解除しない (= 明示 detach は必ず 5 分で回収される) 方が意味論が明快。
|
|
679
|
-
* 閲覧を再開したブラウザは必ず attach を送り直すので、再養子はそちらの経路で足りる。
|
|
756
|
+
* Stage 5: touch で lastActivityMs を更新すると LRU 先頭に留まるため、**閲覧中のセッションは
|
|
757
|
+
* 上限超過の LRU 退避対象にならず、長 idle TTL でも殺されない**(= 実質無期限)。ブラウザを
|
|
758
|
+
* 閉じれば keepalive も止まり、以降は detach 済み warm として LRU / 7 日 idle TTL に従う。
|
|
680
759
|
*
|
|
681
760
|
* @returns {boolean} 生きているセッションを touch できたら true (未知 stream は false)。
|
|
682
761
|
*/
|
|
@@ -688,9 +767,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
688
767
|
}
|
|
689
768
|
|
|
690
769
|
/**
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
770
|
+
* グレースフル切断(Stage 5)。**すぐには reap しない**。detachRequested を立てて
|
|
771
|
+
* warm 保持し、以降は周期 reconcile(LRU 上限 / 長 idle TTL / stuck)に委ねる。これにより
|
|
772
|
+
* 「別 CLI 画面へ切替えた瞬間 5 分で殺される」問題を解消し、直近 5 セッションは 7 日 warm に。
|
|
694
773
|
*/
|
|
695
774
|
detach({ stream_id }) {
|
|
696
775
|
const session = this.sessions.get(stream_id)
|
|
@@ -703,9 +782,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
703
782
|
cwd: session.cwd,
|
|
704
783
|
busy: !!session.activeTurnId,
|
|
705
784
|
},
|
|
706
|
-
"codex detach requested by browser",
|
|
785
|
+
"codex detach requested by browser (kept warm; reconcile handles reap)",
|
|
707
786
|
)
|
|
708
|
-
this._maybeReapAfterIdle(session, "detach")
|
|
709
787
|
}
|
|
710
788
|
|
|
711
789
|
/**
|
|
@@ -790,10 +868,11 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
790
868
|
)
|
|
791
869
|
return false
|
|
792
870
|
}
|
|
793
|
-
//
|
|
794
|
-
//
|
|
795
|
-
//
|
|
796
|
-
|
|
871
|
+
// detach 済み warm セッションでも、ask-sibling が使うなら「新しい持ち主が付いた」とみなして
|
|
872
|
+
// detachRequested を解除し touch する(LRU 先頭へ)。this.input が改めて _touch するが、注入前に
|
|
873
|
+
// 明示しておく。ターン実行中は healthy busy = protected なので reconcile で kill されない。
|
|
874
|
+
best.detachRequested = false
|
|
875
|
+
this._touch(best)
|
|
797
876
|
this.logger?.info?.(
|
|
798
877
|
{
|
|
799
878
|
cwd,
|
|
@@ -816,92 +895,70 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
816
895
|
}
|
|
817
896
|
|
|
818
897
|
/**
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
898
|
+
* Stage 5: セッションが「保護対象(reap 不可)」か。= **healthy な running turn**。
|
|
899
|
+
* activeTurnId が立っており、かつ app-server 通知が STUCK_TTL 以内に届いている(=生きている
|
|
900
|
+
* ターン)。activeTurnId が固着したまま通知が STUCK_TTL 途絶えたもの(stuck/ハング)は
|
|
901
|
+
* protected ではなく reap 対象にする(プロセスリーク・固着の救済)。
|
|
823
902
|
* @param {object} session
|
|
824
|
-
* @param {
|
|
903
|
+
* @param {number} now
|
|
904
|
+
* @returns {boolean}
|
|
825
905
|
*/
|
|
826
|
-
|
|
827
|
-
if (!session.
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
{ stream_id: session.stream_id, thread_id: session.threadId, cwd: session.cwd },
|
|
838
|
-
"codex idle reap canceled at fire time (session re-adopted)",
|
|
839
|
-
)
|
|
840
|
-
session.detachTimer = null
|
|
841
|
-
return
|
|
842
|
-
}
|
|
843
|
-
if (session.activeTurnId) {
|
|
844
|
-
this.logger?.info?.(
|
|
845
|
-
{
|
|
846
|
-
stream_id: session.stream_id,
|
|
847
|
-
thread_id: session.threadId,
|
|
848
|
-
cwd: session.cwd,
|
|
849
|
-
turn_id: session.activeTurnId,
|
|
850
|
-
},
|
|
851
|
-
"codex idle reap deferred (turn in progress; will re-arm on turn/completed)",
|
|
852
|
-
)
|
|
853
|
-
session.detachTimer = null
|
|
854
|
-
return
|
|
855
|
-
}
|
|
856
|
-
this.logger?.info?.(
|
|
857
|
-
{
|
|
858
|
-
stream_id: session.stream_id,
|
|
859
|
-
thread_id: session.threadId,
|
|
860
|
-
cwd: session.cwd,
|
|
861
|
-
ttl_ms: this._detachTtlMs,
|
|
862
|
-
reason: "idle-detach-ttl",
|
|
863
|
-
},
|
|
864
|
-
"codex session reaped (detached and idle past TTL)",
|
|
865
|
-
)
|
|
906
|
+
_isProtected(session, now) {
|
|
907
|
+
if (!session.activeTurnId) return false
|
|
908
|
+
return now - (session.lastActivityMs ?? now) <= this._stuckTurnTtlMs
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** 実際に app-server を止めてセッションを撤去し exit を emit する(reconcile 共通処理)。 */
|
|
912
|
+
_reapSession(session, now, reason) {
|
|
913
|
+
if (session.dead) return
|
|
914
|
+
session.dead = true
|
|
915
|
+
this.sessions.delete(session.stream_id)
|
|
916
|
+
try {
|
|
866
917
|
session.client.stop()
|
|
867
|
-
}
|
|
868
|
-
|
|
918
|
+
} catch (err) {
|
|
919
|
+
this.logger?.warn?.({ stream_id: session.stream_id, err: err?.message }, "codex reap: stop threw")
|
|
920
|
+
}
|
|
869
921
|
this.logger?.info?.(
|
|
870
922
|
{
|
|
871
923
|
stream_id: session.stream_id,
|
|
872
924
|
thread_id: session.threadId,
|
|
873
925
|
cwd: session.cwd,
|
|
874
|
-
|
|
875
|
-
|
|
926
|
+
reason,
|
|
927
|
+
idle_ms: now - (session.lastActivityMs ?? now),
|
|
928
|
+
live_after: this.sessions.size,
|
|
876
929
|
},
|
|
877
|
-
"codex
|
|
930
|
+
"codex session reaped",
|
|
878
931
|
)
|
|
932
|
+
this.emit("exit", {
|
|
933
|
+
stream_id: session.stream_id,
|
|
934
|
+
thread_id: session.threadId,
|
|
935
|
+
code: 0,
|
|
936
|
+
reason: "idle-reaped",
|
|
937
|
+
})
|
|
879
938
|
}
|
|
880
939
|
|
|
881
940
|
/**
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
*
|
|
886
|
-
* @param {
|
|
941
|
+
* Stage 5: LRU warm pool 上限の超過分を退避する。生存(非 dead)セッションが上限を超えたら、
|
|
942
|
+
* **非 protected の中で最終活性が最も古いもの**から順に上限まで reap する。protected(healthy
|
|
943
|
+
* running turn)は退避対象にしないため、全部が busy のときは一時的に上限を超えたまま許容する
|
|
944
|
+
* (走行中ターンを絶対に殺さない)。genuine attach 直後と reconcile の両方から呼ぶ。
|
|
945
|
+
* @param {number} now
|
|
946
|
+
* @returns {number} 退避した数
|
|
887
947
|
*/
|
|
888
|
-
|
|
889
|
-
const
|
|
890
|
-
this.
|
|
891
|
-
if (
|
|
892
|
-
|
|
893
|
-
this.
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
_clearDetachTimer(session) {
|
|
901
|
-
if (session.detachTimer) {
|
|
902
|
-
clearTimeout(session.detachTimer)
|
|
903
|
-
session.detachTimer = null
|
|
948
|
+
_evictWarmPoolOverflow(now, reason = "lru-evict") {
|
|
949
|
+
const live = Array.from(this.sessions.values()).filter((s) => !s.dead)
|
|
950
|
+
const overflow = live.length - this._warmPoolMax
|
|
951
|
+
if (overflow <= 0) return 0
|
|
952
|
+
const evictable = live
|
|
953
|
+
.filter((s) => !this._isProtected(s, now))
|
|
954
|
+
.sort((a, b) => (a.lastActivityMs ?? 0) - (b.lastActivityMs ?? 0)) // 古い順
|
|
955
|
+
let evicted = 0
|
|
956
|
+
for (const s of evictable) {
|
|
957
|
+
if (evicted >= overflow) break
|
|
958
|
+
this._reapSession(s, now, reason)
|
|
959
|
+
evicted += 1
|
|
904
960
|
}
|
|
961
|
+
return evicted
|
|
905
962
|
}
|
|
906
963
|
|
|
907
964
|
_require(stream_id) {
|
|
@@ -913,64 +970,49 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
913
970
|
}
|
|
914
971
|
|
|
915
972
|
/**
|
|
916
|
-
*
|
|
917
|
-
*
|
|
918
|
-
*
|
|
919
|
-
*
|
|
920
|
-
*
|
|
921
|
-
*
|
|
922
|
-
*
|
|
923
|
-
* main.mjs の state loop から claudeBridge.gcDeadTerminals と並べて定期実行する想定。
|
|
924
|
-
*
|
|
925
|
-
* この reaper は `detachRequested` を **意図的に参照しない**。detach を送れずに消えた
|
|
926
|
-
* ブラウザ (タブごとクラッシュ / 端末スリープ) は detachRequested が立たないまま残るため、
|
|
927
|
-
* ここでフラグを見ると回収不能になりリークする。「無通信が続いた事実」だけで回収する。
|
|
973
|
+
* Stage 5: セッションの周期 reconcile(state loop から ~5s 毎に実行)。以下を 1 パスで行う:
|
|
974
|
+
* 1. **stuck turn**: activeTurnId 固着だが app-server 通知が STUCK_TTL 途絶えた(ハング)→ reap
|
|
975
|
+
* (healthy な長ターンは reasoning/output delta で touch されるため該当しない)。
|
|
976
|
+
* 2. **long idle**: 非 busy が idle TTL(既定 7 日)無活性 → reap(warm 保持の期限切れ)。
|
|
977
|
+
* 3. **LRU 上限**: 生存数が warmPoolMax(既定 5)を超えたら、非 protected の最終活性が
|
|
978
|
+
* 最も古いものから上限まで reap。
|
|
979
|
+
* busy(healthy running turn)は 1〜3 いずれの対象にもならない(走行中ターンを絶対に殺さない)。
|
|
928
980
|
*
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
* この reaper は本来の orphan GC として正しく効く。CockpitTerminal.tsx の PTY keepalive
|
|
933
|
-
* (pty.resize で lastSeenAt を触る) と同じ構造。結果として TTL の意味は
|
|
934
|
-
* 「ブラウザ開 = 無期限 / 明示 detach = 5 分 / ブラウザ落ち = 1h reaper」に整理され、
|
|
935
|
-
* 「N 時間放置しても生きていてほしい」という数字合わせ自体が不要になった。
|
|
981
|
+
* 旧 `gcAbandonedSessions`(1h 無活性一律 reap)を置換。detach 済み warm セッションを 1h で
|
|
982
|
+
* 殺していた挙動を廃し、上限+長 TTL に一本化した。main.mjs の state loop 呼び出しは
|
|
983
|
+
* 後方互換のため `gcAbandonedSessions` エイリアスを残す。
|
|
936
984
|
*
|
|
937
|
-
* @param {number} [ttlMs] 無通信とみなす閾値 (テスト用に上書き可)。
|
|
938
985
|
* @returns {number} 回収したセッション数。
|
|
939
986
|
*/
|
|
940
|
-
|
|
987
|
+
reconcileSessions() {
|
|
941
988
|
const now = Date.now()
|
|
942
989
|
let reaped = 0
|
|
990
|
+
// 1〜2: stuck turn / long idle。protected(healthy running turn)はスキップ。
|
|
943
991
|
for (const session of Array.from(this.sessions.values())) {
|
|
944
992
|
if (session.dead) continue
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
this.
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
this.logger?.warn?.({ stream_id: session.stream_id, err: err?.message }, "gcAbandonedSessions: stop threw")
|
|
993
|
+
if (this._isProtected(session, now)) continue
|
|
994
|
+
const idle = now - (session.lastActivityMs ?? now)
|
|
995
|
+
if (session.activeTurnId && idle > this._stuckTurnTtlMs) {
|
|
996
|
+
this._reapSession(session, now, "stuck-turn")
|
|
997
|
+
reaped += 1
|
|
998
|
+
} else if (!session.activeTurnId && idle > this._detachTtlMs) {
|
|
999
|
+
this._reapSession(session, now, "idle-ttl")
|
|
1000
|
+
reaped += 1
|
|
954
1001
|
}
|
|
955
|
-
reaped += 1
|
|
956
|
-
this.logger?.info?.(
|
|
957
|
-
{ stream_id: session.stream_id, thread_id: session.threadId, idle_ms: now - last },
|
|
958
|
-
"codex abandoned session reaped (no activity past TTL)",
|
|
959
|
-
)
|
|
960
|
-
this.emit("exit", {
|
|
961
|
-
stream_id: session.stream_id,
|
|
962
|
-
thread_id: session.threadId,
|
|
963
|
-
code: 0,
|
|
964
|
-
reason: "idle-reaped",
|
|
965
|
-
})
|
|
966
1002
|
}
|
|
1003
|
+
// 3: LRU 上限の超過分を退避(stuck/idle reap 後の生存数で判定)。
|
|
1004
|
+
reaped += this._evictWarmPoolOverflow(now, "lru-evict")
|
|
967
1005
|
return reaped
|
|
968
1006
|
}
|
|
969
1007
|
|
|
1008
|
+
/** 後方互換エイリアス(main.mjs state loop が呼ぶ)。中身は reconcileSessions。 */
|
|
1009
|
+
gcAbandonedSessions() {
|
|
1010
|
+
return this.reconcileSessions()
|
|
1011
|
+
}
|
|
1012
|
+
|
|
970
1013
|
/** 全セッションを即座に終了する (agent shutdown 用。ターン完走は待たない)。 */
|
|
971
1014
|
shutdown() {
|
|
972
1015
|
for (const session of this.sessions.values()) {
|
|
973
|
-
this._clearDetachTimer(session)
|
|
974
1016
|
session.dead = true
|
|
975
1017
|
// hub-agent 再起動時にターン実行中のセッションを落とすと thread が汚染されうる
|
|
976
1018
|
// (exec 中の kill → output 欠落)。因果を後から辿れるよう必ず記録する。
|
package/src/main.mjs
CHANGED
|
@@ -4210,6 +4210,9 @@ async function dispatch(msg, ctx) {
|
|
|
4210
4210
|
// consumers.py の per-stream relay は content-blind なので backend 側の
|
|
4211
4211
|
// 変更は不要 (_AGENT_PER_STREAM_TYPES の codex.ready がそのまま運ぶ)。
|
|
4212
4212
|
history: info.history || [],
|
|
4213
|
+
// T06499: 巨大スレッドで末尾 N ターンに絞った場合 true。browser は
|
|
4214
|
+
// 「以前の履歴は省略されています」バナーを出す (会話は resume 済みで継続可)。
|
|
4215
|
+
history_truncated: info.historyTruncated === true,
|
|
4213
4216
|
// 案C (権威的ターン状態): フロントは会話末尾からの推測 (deriveTurnActive
|
|
4214
4217
|
// FromHistory) をやめ、Codex が明示する Thread.status で turnActive を決める。
|
|
4215
4218
|
// resume 後は新プロセスで実行中ターンが無く thread_status='idle' が返るため、
|
|
@@ -4396,11 +4399,23 @@ async function dispatch(msg, ctx) {
|
|
|
4396
4399
|
}
|
|
4397
4400
|
try {
|
|
4398
4401
|
const sessions = await ctx.codexBridge.listThreads({ cwd, limit })
|
|
4402
|
+
// Stage 5 ③: 各 thread に「hub-agent 側で app-server が live か / busy か」を付す。
|
|
4403
|
+
// rollout 由来の一覧(listThreads)は live 状態を知らないため、describeLiveSessions
|
|
4404
|
+
// の thread_id → {live, busy} を併合する。browser は sidebar に live/reaped ドットを出す。
|
|
4405
|
+
const liveByThread = new Map()
|
|
4406
|
+
for (const s of ctx.codexBridge.describeLiveSessions(cwd)) {
|
|
4407
|
+
if (s.thread_id) liveByThread.set(s.thread_id, { live: true, busy: !!s.busy })
|
|
4408
|
+
}
|
|
4409
|
+
const withLive = sessions.map((t) => ({
|
|
4410
|
+
...t,
|
|
4411
|
+
live: liveByThread.get(t.thread_id)?.live === true,
|
|
4412
|
+
busy: liveByThread.get(t.thread_id)?.busy === true,
|
|
4413
|
+
}))
|
|
4399
4414
|
ctx.client.send({
|
|
4400
4415
|
type: "codex.sessions.response",
|
|
4401
4416
|
stream_id,
|
|
4402
4417
|
cwd,
|
|
4403
|
-
sessions,
|
|
4418
|
+
sessions: withLive,
|
|
4404
4419
|
})
|
|
4405
4420
|
} catch (err) {
|
|
4406
4421
|
ctx.client.send({
|