@cocorograph/hub-agent 0.7.72 → 0.7.73
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 +79 -7
- package/src/main.mjs +3 -0
package/package.json
CHANGED
|
@@ -93,15 +93,61 @@ const ABANDONED_SESSION_TTL_MS =
|
|
|
93
93
|
|
|
94
94
|
const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
|
|
95
95
|
|
|
96
|
+
// T06499 (2026-07-18): 巨大スレッドの resume ガード。長期運用したスレッドの rollout は
|
|
97
|
+
// 20MB 超まで肥大し得る (実測: メイン repo 540KB が正常なのに対し 20MB のスレッドあり)。
|
|
98
|
+
// thread/resume 応答をそのまま平坦化して browser へ送ると、
|
|
99
|
+
// ① WS へ 20MB の JSON を積む → 転送で数秒フリーズ
|
|
100
|
+
// ② browser 側で全 item を codexHistoryToEvents → reducer merge → 数千バブル描画で
|
|
101
|
+
// メインスレッドがハング (履歴も、その裏で撃つ codex.sessions.request 由来の
|
|
102
|
+
// セッション切替バーも一切出ない = UX の断崖)
|
|
103
|
+
// が起きる。thread/resume 自体はモデル側の会話コンテキスト復元のため全量が必要だが、
|
|
104
|
+
// **browser へ渡す表示用履歴は末尾 N ターン分に絞る** ことで断崖を回避する。上限は
|
|
105
|
+
// 「item 数」と「概算バイト数」の 2 軸で、末尾から詰めてどちらか先に達したら truncated。
|
|
106
|
+
const HISTORY_MAX_ITEMS = Number(process.env.HUB_AGENT_CODEX_HISTORY_MAX_ITEMS) || 200
|
|
107
|
+
const HISTORY_MAX_BYTES =
|
|
108
|
+
Number(process.env.HUB_AGENT_CODEX_HISTORY_MAX_BYTES) || 1.5 * 1024 * 1024
|
|
109
|
+
|
|
96
110
|
// Slice 6: thread/resume が返す Thread.turns[].items[] を単一の時系列配列へ平坦化する。
|
|
97
111
|
// turns は開始ターン順の配列、各 turn.items も時系列順のため単純結合で会話順を保てる。
|
|
98
|
-
|
|
112
|
+
//
|
|
113
|
+
// T06499: 末尾から maxItems / maxBytes の budget 内に収め、超過分は落として truncated=true を
|
|
114
|
+
// 返す。末尾優先なのは「直近の会話が最も価値が高く、古い履歴は resume されたモデル側が保持して
|
|
115
|
+
// いる」ため。budget を末尾から積むので、20MB スレッドでも全 item を stringify せず早期に打ち切る
|
|
116
|
+
// (kept が budget に達した時点で残りの巨大な古い item には触れない)。
|
|
117
|
+
// @returns {{items: object[], truncated: boolean, total: number}}
|
|
118
|
+
function flattenThreadItems(thread, { maxItems = HISTORY_MAX_ITEMS, maxBytes = HISTORY_MAX_BYTES } = {}) {
|
|
99
119
|
const turns = Array.isArray(thread?.turns) ? thread.turns : []
|
|
100
|
-
const
|
|
120
|
+
const all = []
|
|
101
121
|
for (const turn of turns) {
|
|
102
|
-
if (Array.isArray(turn?.items))
|
|
122
|
+
if (Array.isArray(turn?.items)) all.push(...turn.items)
|
|
103
123
|
}
|
|
104
|
-
|
|
124
|
+
const total = all.length
|
|
125
|
+
const kept = []
|
|
126
|
+
let bytes = 0
|
|
127
|
+
let truncated = false
|
|
128
|
+
// 末尾 (最新) から詰める。budget 超過で打ち切り、truncated を立てる。
|
|
129
|
+
for (let i = all.length - 1; i >= 0; i--) {
|
|
130
|
+
if (kept.length >= maxItems) {
|
|
131
|
+
truncated = true
|
|
132
|
+
break
|
|
133
|
+
}
|
|
134
|
+
const item = all[i]
|
|
135
|
+
let size = 0
|
|
136
|
+
try {
|
|
137
|
+
size = JSON.stringify(item)?.length || 0
|
|
138
|
+
} catch {
|
|
139
|
+
size = 0
|
|
140
|
+
}
|
|
141
|
+
// 最低 1 件は必ず入れる (単一 item が maxBytes を超えても末尾 1 件は見せる)。
|
|
142
|
+
if (kept.length > 0 && bytes + size > maxBytes) {
|
|
143
|
+
truncated = true
|
|
144
|
+
break
|
|
145
|
+
}
|
|
146
|
+
kept.push(item)
|
|
147
|
+
bytes += size
|
|
148
|
+
}
|
|
149
|
+
kept.reverse() // 末尾から積んだので時系列順へ戻す。
|
|
150
|
+
return { items: kept, truncated, total }
|
|
105
151
|
}
|
|
106
152
|
|
|
107
153
|
/**
|
|
@@ -184,7 +230,15 @@ function userInputFromMessage(message) {
|
|
|
184
230
|
}
|
|
185
231
|
|
|
186
232
|
export class CodexStreamBridge extends EventEmitter {
|
|
187
|
-
constructor({
|
|
233
|
+
constructor({
|
|
234
|
+
codexCmd,
|
|
235
|
+
logger,
|
|
236
|
+
clientFactory,
|
|
237
|
+
detachTtlMs,
|
|
238
|
+
rolloutPresence,
|
|
239
|
+
historyMaxItems,
|
|
240
|
+
historyMaxBytes,
|
|
241
|
+
} = {}) {
|
|
188
242
|
super()
|
|
189
243
|
this.codexCmd = codexCmd
|
|
190
244
|
this.logger = logger
|
|
@@ -193,6 +247,11 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
193
247
|
this._detachTtlMs = detachTtlMs ?? IDLE_DETACH_TTL_MS
|
|
194
248
|
// #4 失敗分類の rollout 存在チェック (tri-state。テストから注入可能)。
|
|
195
249
|
this._rolloutPresence = rolloutPresence || codexRolloutPresence
|
|
250
|
+
// T06499: 履歴 hydrate の上限 (テストから小さい値を注入可能)。
|
|
251
|
+
this._historyLimits = {
|
|
252
|
+
maxItems: historyMaxItems ?? HISTORY_MAX_ITEMS,
|
|
253
|
+
maxBytes: historyMaxBytes ?? HISTORY_MAX_BYTES,
|
|
254
|
+
}
|
|
196
255
|
/** @type {Map<string, object>} stream_id -> session record */
|
|
197
256
|
this.sessions = new Map()
|
|
198
257
|
}
|
|
@@ -210,7 +269,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
210
269
|
* なければ、通常の attach (thread/start | thread/resume) にフォールスルーする。
|
|
211
270
|
*
|
|
212
271
|
* @returns {Promise<{resuming: boolean, threadId: string, history: object[],
|
|
213
|
-
* threadStatus: string|null, lastTurnStatus: string|null,
|
|
272
|
+
* historyTruncated: boolean, threadStatus: string|null, lastTurnStatus: string|null,
|
|
273
|
+
* turnActive: boolean}>}
|
|
214
274
|
*/
|
|
215
275
|
async attach({ stream_id, cwd, model, effort, approvalPolicy, sandbox, resumeThreadId, attachId }) {
|
|
216
276
|
if (this.sessions.has(stream_id)) {
|
|
@@ -292,7 +352,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
292
352
|
session.threadId = result?.thread?.id
|
|
293
353
|
// 新規スレッド (thread/start) は履歴が無いので空配列。resume 時のみ平坦化する
|
|
294
354
|
// (thread/start のレスポンスは turns を持たない仕様のため呼んでも常に空)。
|
|
295
|
-
|
|
355
|
+
// T06499: 巨大スレッドは末尾 N ターンに絞る (truncated=true なら browser がバナー表示)。
|
|
356
|
+
const flat = resuming
|
|
357
|
+
? flattenThreadItems(result?.thread, this._historyLimits)
|
|
358
|
+
: { items: [], truncated: false, total: 0 }
|
|
359
|
+
const history = flat.items
|
|
360
|
+
const historyTruncated = flat.truncated
|
|
296
361
|
// 案C: 権威的なターン状態を resume 応答から取り出してフロントへ渡す。
|
|
297
362
|
// これによりフロントは会話末尾からの推測をやめ、Thread.status で turnActive を
|
|
298
363
|
// 決められる (生成中スピナー固着バグの根治)。新規スレッドは常に idle 相当。
|
|
@@ -317,6 +382,10 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
317
382
|
resuming,
|
|
318
383
|
thread_status: turnState.threadStatus,
|
|
319
384
|
live_for_cwd: this.describeLiveSessions(cwd).length,
|
|
385
|
+
// T06499: 巨大スレッド hydrate の可視化 (何件中何件を返したか)。
|
|
386
|
+
history_items: history.length,
|
|
387
|
+
history_total: flat.total,
|
|
388
|
+
history_truncated: historyTruncated,
|
|
320
389
|
},
|
|
321
390
|
"codex session attached",
|
|
322
391
|
)
|
|
@@ -324,6 +393,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
324
393
|
resuming,
|
|
325
394
|
threadId: session.threadId,
|
|
326
395
|
history,
|
|
396
|
+
historyTruncated,
|
|
327
397
|
threadStatus: turnState.threadStatus,
|
|
328
398
|
lastTurnStatus: turnState.lastTurnStatus,
|
|
329
399
|
turnActive: turnState.turnActive,
|
|
@@ -389,6 +459,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
389
459
|
resuming: true,
|
|
390
460
|
threadId,
|
|
391
461
|
history: [],
|
|
462
|
+
// reconnect の in-memory reattach は表示を保持しているので履歴を返さない = 常に非 truncated。
|
|
463
|
+
historyTruncated: false,
|
|
392
464
|
threadStatus: session.activeTurnId ? "active" : "idle",
|
|
393
465
|
lastTurnStatus: null,
|
|
394
466
|
turnActive: !!session.activeTurnId,
|
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' が返るため、
|