@cocorograph/hub-agent 0.7.71 → 0.7.72
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 +171 -14
- package/src/main.mjs +16 -2
- package/src/usage.mjs +61 -0
package/package.json
CHANGED
|
@@ -16,11 +16,15 @@
|
|
|
16
16
|
* claude.event (生 SDK イベント素通し) → 'event' (生 notification 素通し、同じ思想)
|
|
17
17
|
* claude.permission.request → 'permission' (承認/入力要求の生 method+params 素通し)
|
|
18
18
|
*
|
|
19
|
+
* ## in-memory reattach (Stage4 caller レビューで追加)
|
|
20
|
+
* `attachId` を伴う attach は、同じ attach_id のライブ session を **新 stream_id へ付け替える**
|
|
21
|
+
* (新 app-server を起動しない)。browser の WS 再接続で stream_id が変わっても、fresh 直後
|
|
22
|
+
* (rollout 0件) の会話も含めて復元できる。詳細は attach() の docstring 参照。
|
|
23
|
+
*
|
|
19
24
|
* Slice 2 のスコープ外 (意図的に見送り、後続スライスで追加する):
|
|
20
|
-
* -
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* thread/resume で再ロードする」場合のみ扱う。
|
|
25
|
+
* - 複数ブラウザタブでの **同時** 同一スレッド共有 (Claude の HUB_AGENT_CHAT_SHARED 相当)。
|
|
26
|
+
* in-memory reattach は「1 つの持ち主が WS を張り直す」ケース (単一勝者) であり、複数タブが
|
|
27
|
+
* 同時に同一スレッドを操作する共有はまだ扱わない。
|
|
24
28
|
* - 画像/添付入力 (テキストのみ)。
|
|
25
29
|
* - acceptForSession や execpolicy/network amendment 等の高度な承認 decision
|
|
26
30
|
* (permissionReply は decision をそのまま素通しするので呼び出し側が組み立てる)。
|
|
@@ -36,6 +40,7 @@ import { EventEmitter } from "node:events"
|
|
|
36
40
|
|
|
37
41
|
import { CodexAppServerClient } from "./codex-appserver-client.mjs"
|
|
38
42
|
import { getOrCreateSiblingToken } from "./sibling-token.mjs"
|
|
43
|
+
import { codexRolloutPresence } from "./usage.mjs"
|
|
39
44
|
|
|
40
45
|
/**
|
|
41
46
|
* sibling-inject (2026-07-04) 用: spawn する codex app-server プロセス自身の
|
|
@@ -67,11 +72,12 @@ function siblingEnvFor(cwd) {
|
|
|
67
72
|
// 全ターンで出続ける)。
|
|
68
73
|
// 予約は detach 済みセッションに限り、発火時にも busy/再活性を再確認すること。
|
|
69
74
|
// detach 後、非 busy なセッションを生かしておく猶予。claude 側 (7 日) と違い短くする:
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
// rollout
|
|
74
|
-
//
|
|
75
|
+
// この TTL は **in-memory reattach の猶予窓** を兼ねる。WS が切れて browser disconnect が
|
|
76
|
+
// codex.detach を送っても、この TTL 内に **同じ attach_id で再接続** すれば _tryReattach が
|
|
77
|
+
// その session を新 stream へ付け替えて拾える (fresh 直後 rollout 0件の会話も復元可)。TTL を
|
|
78
|
+
// 超えて reap されたら、再接続は通常経路 (thread/resume は rollout 正本から復元、または fresh)
|
|
79
|
+
// にフォールスルーする。7 日だと再接続チャーンのたびにプロセスが積み上がる (リーク) ため短命に
|
|
80
|
+
// する。ターン実行中は TTL に
|
|
75
81
|
// 関わらず完走まで絶対に落とさない (_maybeReapAfterIdle が busy 中は予約しない)。
|
|
76
82
|
const IDLE_DETACH_TTL_MS = 5 * 60 * 1000
|
|
77
83
|
|
|
@@ -178,34 +184,76 @@ function userInputFromMessage(message) {
|
|
|
178
184
|
}
|
|
179
185
|
|
|
180
186
|
export class CodexStreamBridge extends EventEmitter {
|
|
181
|
-
constructor({ codexCmd, logger, clientFactory, detachTtlMs } = {}) {
|
|
187
|
+
constructor({ codexCmd, logger, clientFactory, detachTtlMs, rolloutPresence } = {}) {
|
|
182
188
|
super()
|
|
183
189
|
this.codexCmd = codexCmd
|
|
184
190
|
this.logger = logger
|
|
185
191
|
this._clientFactory = clientFactory || ((opts) => new CodexAppServerClient(opts))
|
|
186
192
|
// テストから TTL を短縮できるよう注入可能にする (既定は 7 日)。
|
|
187
193
|
this._detachTtlMs = detachTtlMs ?? IDLE_DETACH_TTL_MS
|
|
194
|
+
// #4 失敗分類の rollout 存在チェック (tri-state。テストから注入可能)。
|
|
195
|
+
this._rolloutPresence = rolloutPresence || codexRolloutPresence
|
|
188
196
|
/** @type {Map<string, object>} stream_id -> session record */
|
|
189
197
|
this.sessions = new Map()
|
|
190
198
|
}
|
|
191
199
|
|
|
192
200
|
/**
|
|
201
|
+
* Codex セッションへ attach する。
|
|
202
|
+
*
|
|
203
|
+
* ## in-memory reattach (Stage4 caller レビュー major #1)
|
|
204
|
+
* `attachId` は「1 つの attach 試行」の安定 id。browser の WS が attach〜利用中に切れて
|
|
205
|
+
* **同じ attach_id で再接続**してきたら、新しい app-server を起動して thread/resume するのでは
|
|
206
|
+
* なく、**既にメモリ上で生きている session をこの新しい stream_id へ付け替える**(所有権と
|
|
207
|
+
* イベントルーティングを移す)。これにより「fresh thread を作成したが初回入力前で rollout が
|
|
208
|
+
* まだ 0 件」の状態でも復元でき、二重 thread/start も起きない (rollout 依存の thread/resume は
|
|
209
|
+
* このケースを復元できない)。live session が既に reap 済み / hub-agent 再起動後などで見つから
|
|
210
|
+
* なければ、通常の attach (thread/start | thread/resume) にフォールスルーする。
|
|
211
|
+
*
|
|
193
212
|
* @returns {Promise<{resuming: boolean, threadId: string, history: object[],
|
|
194
213
|
* threadStatus: string|null, lastTurnStatus: string|null, turnActive: boolean}>}
|
|
195
214
|
*/
|
|
196
|
-
async attach({ stream_id, cwd, model, effort, approvalPolicy, sandbox, resumeThreadId }) {
|
|
215
|
+
async attach({ stream_id, cwd, model, effort, approvalPolicy, sandbox, resumeThreadId, attachId }) {
|
|
197
216
|
if (this.sessions.has(stream_id)) {
|
|
198
217
|
throw new Error(`stream_id already attached: ${stream_id}`)
|
|
199
218
|
}
|
|
219
|
+
// #1: 同じ attach_id のライブ session があれば in-memory reattach を試みる。
|
|
220
|
+
if (attachId) {
|
|
221
|
+
const existing = this._findLiveSessionByAttachId(attachId)
|
|
222
|
+
if (existing && existing.stream_id !== stream_id) {
|
|
223
|
+
const r = await this._tryReattach(existing, stream_id)
|
|
224
|
+
if (r.status === "reattached") return r.info
|
|
225
|
+
if (r.status === "superseded") {
|
|
226
|
+
// 別 reattach が勝って session を奪った (concurrent reconnect)。**通常経路へ落とさない**
|
|
227
|
+
// — 落とすと敗者が別 thread を thread/start してしまい I2 (fresh 最大1回) を再び破る /
|
|
228
|
+
// 同一 thread の app-server が複数化する (major #1 ラウンド5)。二重 thread 副作用を作らず
|
|
229
|
+
// superseded として失敗させる (実運用では同 attach_id の同時 attach は起きない防御。
|
|
230
|
+
// 起きても実 thread/start は勝者の 1 本だけに保たれる)。
|
|
231
|
+
const err = new Error("attach superseded by a concurrent reattach")
|
|
232
|
+
err.attachOperation = "reattach"
|
|
233
|
+
err.attachReason = "superseded"
|
|
234
|
+
throw err
|
|
235
|
+
}
|
|
236
|
+
// r.status === "unavailable": session が (await 中に) 死んだ / first の thread 確定に失敗。
|
|
237
|
+
// = ライブ session はもう無い → 通常経路 (thread/resume or fresh) へフォールスルーしてよい
|
|
238
|
+
// (二重生成の相手が居ないため)。
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
200
242
|
const client = this._clientFactory({
|
|
201
243
|
cwd,
|
|
202
244
|
codexCmd: this.codexCmd,
|
|
203
245
|
logger: this.logger,
|
|
204
246
|
env: siblingEnvFor(cwd),
|
|
205
247
|
})
|
|
248
|
+
// #2: この session の thread 確定を待つ単一 promise。reattach の待ち合わせ (in-flight) に使う。
|
|
249
|
+
// **成功で threadId、失敗で null を resolve** する (reject しない = 待ち手がいなくても
|
|
250
|
+
// unhandled rejection を作らず、複数の reconnect が同じ promise を安全に await できる)。
|
|
251
|
+
let settleReady
|
|
206
252
|
const session = {
|
|
207
253
|
stream_id,
|
|
208
254
|
cwd,
|
|
255
|
+
// #1 in-memory reattach のキー。attach 試行を跨いで安定 (WS 再接続で stream_id は変わる)。
|
|
256
|
+
attachId: attachId || null,
|
|
209
257
|
client,
|
|
210
258
|
threadId: null,
|
|
211
259
|
activeTurnId: null,
|
|
@@ -219,14 +267,22 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
219
267
|
// 放棄セッション reaper (gcAbandonedSessions) 用の最終活性時刻。browser 入力と
|
|
220
268
|
// app-server 通知 (ターン進捗) の両方で更新する。Date.now() は hub-agent 実行環境で利用可。
|
|
221
269
|
lastActivityMs: Date.now(),
|
|
270
|
+
ready: null,
|
|
222
271
|
}
|
|
272
|
+
session.ready = new Promise((resolve) => {
|
|
273
|
+
settleReady = resolve
|
|
274
|
+
})
|
|
223
275
|
this.sessions.set(stream_id, session)
|
|
224
276
|
this._wireClient(session)
|
|
225
277
|
client.start()
|
|
226
278
|
|
|
279
|
+
const resuming = !!resumeThreadId
|
|
280
|
+
// #3: initialize と thread op を分離し、thread op に到達したかを記録する。initialize / config
|
|
281
|
+
// / MCP 起動の失敗 (thread op 未到達) は attach 失敗分類の対象外にする (pointer を消さない)。
|
|
282
|
+
let reachedThreadOp = false
|
|
227
283
|
try {
|
|
228
284
|
await client.request("initialize", { clientInfo: CLIENT_INFO })
|
|
229
|
-
|
|
285
|
+
reachedThreadOp = true
|
|
230
286
|
const startParams = { cwd, model: model || undefined, sandbox: sandbox || undefined }
|
|
231
287
|
if (effort) startParams.effort = effort
|
|
232
288
|
if (approvalPolicy) startParams.approvalPolicy = approvalPolicy
|
|
@@ -246,6 +302,10 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
246
302
|
// Codex が「ターン実行中」と権威的に報告した場合のみ activeTurnId を復元する
|
|
247
303
|
// (通常の fresh resume では null = 次入力が turn/start になり固着しない)。
|
|
248
304
|
session.activeTurnId = turnState.activeTurnId
|
|
305
|
+
// minor #3: session の権威状態 (activeTurnId) を **設定し終えてから** reattach 待ちへ
|
|
306
|
+
// resolve する。先に resolve すると、in-flight 中の reconnect が activeTurnId 未設定
|
|
307
|
+
// (turnActive=false) の状態で reattach し得るため、初期化完了後にスナップショットで通知する。
|
|
308
|
+
settleReady(session.threadId || null)
|
|
249
309
|
// セッションのライフサイクルは必ずログに残す (2026-07-17: attach/detach/reap/exit の
|
|
250
310
|
// 記録が皆無で「なぜ消えたか」を事後に辿れなかった)。同一 cwd に何本のセッションが
|
|
251
311
|
// 積み上がっているかも併記し、ゾンビ蓄積をログだけで検知できるようにする。
|
|
@@ -271,10 +331,101 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
271
331
|
} catch (err) {
|
|
272
332
|
this.sessions.delete(stream_id)
|
|
273
333
|
client.stop()
|
|
334
|
+
settleReady(null) // reattach 待ちへ「失敗」を通知 (reject しない)
|
|
335
|
+
// #3/#4: thread op に到達した失敗だけ分類する (initialize 失敗は分類しない)。
|
|
336
|
+
if (reachedThreadOp) await this._classifyAttachFailure(err, { resuming, resumeThreadId })
|
|
274
337
|
throw err
|
|
275
338
|
}
|
|
276
339
|
}
|
|
277
340
|
|
|
341
|
+
/** attach_id に一致するライブ session を返す (in-memory reattach 用。無ければ null)。 */
|
|
342
|
+
_findLiveSessionByAttachId(attachId) {
|
|
343
|
+
if (!attachId) return null
|
|
344
|
+
for (const s of this.sessions.values()) {
|
|
345
|
+
if (!s.dead && s.attachId === attachId) return s
|
|
346
|
+
}
|
|
347
|
+
return null
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* 既存ライブ session を新 stream_id へ付け替える (in-memory reattach)。thread が未確定
|
|
352
|
+
* (thread/start in-flight) なら session.ready を await する (成功=threadId / 失敗=null)。
|
|
353
|
+
* 付け替えられなければ null を返し、呼び出し側 attach() は通常経路へフォールスルーする。
|
|
354
|
+
*/
|
|
355
|
+
async _tryReattach(session, newStreamId) {
|
|
356
|
+
// ⚠️ 付け替え前の stream_id を **await の前に capture** する (major #1: 同じ attach_id へ
|
|
357
|
+
// 複数 reconnect が session.ready 待ちで重なったとき、`session.stream_id` を await 後に
|
|
358
|
+
// 読むと勝者が既に付け替えた現在値を追ってしまい「別 stream へ移動済み」を検出できず、
|
|
359
|
+
// 敗者も成功してしまう。capture 値で CAS することで **単一勝者**にする)。
|
|
360
|
+
// 戻り値 status:
|
|
361
|
+
// "reattached" - この呼び出しが session を newStreamId へ付け替えた (info 同梱)。
|
|
362
|
+
// "superseded" - 別 reattach が勝って session を奪った (session は生きている)。呼び出し側は
|
|
363
|
+
// **通常経路に落とさず** 失敗させる (二重 thread を作らない)。
|
|
364
|
+
// "unavailable"- session が死んだ / first の thread 確定に失敗 (ライブ session 無し)。
|
|
365
|
+
// 呼び出し側は通常経路へフォールスルーしてよい。
|
|
366
|
+
const originalStreamId = session.stream_id
|
|
367
|
+
let threadId = session.threadId
|
|
368
|
+
if (!threadId && session.ready) threadId = await session.ready
|
|
369
|
+
// first の thread op が失敗 / session が死んだ = ライブ session はもう無い。
|
|
370
|
+
if (!threadId || session.dead) return { status: "unavailable" }
|
|
371
|
+
// await 中に別 reattach が勝って session を移動した = concurrent の敗者。
|
|
372
|
+
if (this.sessions.get(originalStreamId) !== session) return { status: "superseded" }
|
|
373
|
+
if (this.sessions.has(newStreamId)) return { status: "superseded" } // 新 stream 側が別 session 保持 (異常)
|
|
374
|
+
this.sessions.delete(originalStreamId)
|
|
375
|
+
session.stream_id = newStreamId
|
|
376
|
+
session.detachRequested = false
|
|
377
|
+
this._cancelIdleReap(session, "reattach")
|
|
378
|
+
session.lastActivityMs = Date.now()
|
|
379
|
+
this.sessions.set(newStreamId, session)
|
|
380
|
+
this.logger?.info?.(
|
|
381
|
+
{ stream_id: newStreamId, thread_id: threadId, cwd: session.cwd, reattached: true },
|
|
382
|
+
"codex session reattached (in-memory)",
|
|
383
|
+
)
|
|
384
|
+
// 履歴は返さない: reconnect では frontend が表示を保持している (resetDisplay=false) ため
|
|
385
|
+
// 再 hydrate 不要。turnActive は session が保持する activeTurnId から権威的に決める。
|
|
386
|
+
return {
|
|
387
|
+
status: "reattached",
|
|
388
|
+
info: {
|
|
389
|
+
resuming: true,
|
|
390
|
+
threadId,
|
|
391
|
+
history: [],
|
|
392
|
+
threadStatus: session.activeTurnId ? "active" : "idle",
|
|
393
|
+
lastTurnStatus: null,
|
|
394
|
+
turnActive: !!session.activeTurnId,
|
|
395
|
+
},
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* resume/start 失敗を frontend が扱える reason に分類する (Stage4 caller レビュー major #3/#4)。
|
|
401
|
+
* - resume: **timeout を最優先** (thread/resume が timeout なら rollout 有無に関係なく timeout)。
|
|
402
|
+
* それ以外は rollout の tri-state (present/absent/unknown) で判定し、**absent のときだけ**
|
|
403
|
+
* thread_not_found (frontend が pointer を compare-delete してよい)。unknown/present は
|
|
404
|
+
* resume_error (有効 pointer を保持する)。
|
|
405
|
+
* - start (fresh): thread_not_found はあり得ない。timeout か start_error。
|
|
406
|
+
* initialize 段階の失敗はここに来ない (呼び出し側が reachedThreadOp で gate 済み)。
|
|
407
|
+
*/
|
|
408
|
+
async _classifyAttachFailure(err, { resuming, resumeThreadId }) {
|
|
409
|
+
if (resuming) {
|
|
410
|
+
err.attachOperation = "resume"
|
|
411
|
+
err.attachThreadId = resumeThreadId
|
|
412
|
+
if (err.reason === "timeout") {
|
|
413
|
+
err.attachReason = "timeout"
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
let presence = "unknown"
|
|
417
|
+
try {
|
|
418
|
+
presence = await this._rolloutPresence(resumeThreadId)
|
|
419
|
+
} catch {
|
|
420
|
+
presence = "unknown"
|
|
421
|
+
}
|
|
422
|
+
err.attachReason = presence === "absent" ? "thread_not_found" : "resume_error"
|
|
423
|
+
} else {
|
|
424
|
+
err.attachOperation = "start"
|
|
425
|
+
err.attachReason = err.reason === "timeout" ? "timeout" : "start_error"
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
278
429
|
/**
|
|
279
430
|
* cwd 配下の Codex thread 一覧を返す (Cockpit Codex 両対応 セッションピッカー/最新
|
|
280
431
|
* セッション自動選択用。claude 側 listSessions と対称)。
|
|
@@ -317,12 +468,18 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
317
468
|
}
|
|
318
469
|
|
|
319
470
|
_wireClient(session) {
|
|
320
|
-
const { client
|
|
471
|
+
const { client } = session
|
|
321
472
|
client.on("notification", (msg) => this._onNotification(session, msg))
|
|
322
473
|
client.on("request", (msg) => this._onServerRequest(session, msg))
|
|
323
474
|
client.on("exit", (info) => this._onClientExit(session, info))
|
|
324
475
|
client.on("error", (err) => {
|
|
325
|
-
|
|
476
|
+
// ⚠️ session.stream_id を **都度読む** (in-memory reattach で付け替わるため。wire 時に
|
|
477
|
+
// 分割代入で固定すると reattach 後に旧 stream へ error を emit してしまう)。
|
|
478
|
+
this.emit("error", {
|
|
479
|
+
stream_id: session.stream_id,
|
|
480
|
+
thread_id: session.threadId,
|
|
481
|
+
error: err.message || String(err),
|
|
482
|
+
})
|
|
326
483
|
})
|
|
327
484
|
}
|
|
328
485
|
|
package/src/main.mjs
CHANGED
|
@@ -4197,6 +4197,9 @@ async function dispatch(msg, ctx) {
|
|
|
4197
4197
|
approvalPolicy: msg.approval_policy || undefined,
|
|
4198
4198
|
sandbox: msg.sandbox || undefined,
|
|
4199
4199
|
resumeThreadId,
|
|
4200
|
+
// Stage4 caller レビュー major #1: attach 試行の安定 id。同じ attach_id で再接続
|
|
4201
|
+
// すると bridge が rebind し、fresh の二重生成を防ぐ (browser の stream_id は都度変わる)。
|
|
4202
|
+
attachId: msg.attach_id || undefined,
|
|
4200
4203
|
})
|
|
4201
4204
|
ctx.client.send({
|
|
4202
4205
|
type: "codex.ready",
|
|
@@ -4223,7 +4226,11 @@ async function dispatch(msg, ctx) {
|
|
|
4223
4226
|
// - fresh (resume 無し): ゼロ値 + 確定済み threadId で即時 push (症状④)。
|
|
4224
4227
|
// - resume: codexUsageForThread で実 context% を解決し即時 push (症状③)。
|
|
4225
4228
|
if (resolvedCwd) {
|
|
4226
|
-
|
|
4229
|
+
// Stage4 caller レビュー major #6: fresh/resume の分岐は **実際の attach 結果**
|
|
4230
|
+
// (info.resuming) で判定する。in-memory reattach は元 msg.resume_thread_id が空
|
|
4231
|
+
// (fresh 再接続) でも既存 thread への復帰 = info.resuming=true なので、resume 側の
|
|
4232
|
+
// 実 context% 解決に載せる (fresh 扱いで context を 0 に戻さない)。
|
|
4233
|
+
if (!info.resuming) {
|
|
4227
4234
|
clearChatSignal(resolvedCwd, "codex")
|
|
4228
4235
|
recordChatActivity(resolvedCwd, "codex", {
|
|
4229
4236
|
status: "waiting",
|
|
@@ -4272,7 +4279,14 @@ async function dispatch(msg, ctx) {
|
|
|
4272
4279
|
// S3 Codex 堅牢化: request() タイムアウト (codex-appserver-client.mjs) は
|
|
4273
4280
|
// err.reason='timeout' を付与する。frontend が理由別の文言/表示を出し分け
|
|
4274
4281
|
// られるよう素通しする (無ければ undefined = キー自体を省略)。
|
|
4275
|
-
|
|
4282
|
+
// Stage4 caller レビュー major #3: attach 失敗は bridge が operation (resume|start) と
|
|
4283
|
+
// reason (thread_not_found|timeout|resume_error|start_error) を分類する。frontend は
|
|
4284
|
+
// operation=resume かつ reason=thread_not_found のときだけ pointer を修復して既存
|
|
4285
|
+
// thread へ fallback する (I5)。err.attachReason があればそれを優先する。
|
|
4286
|
+
...(err.attachReason || err.reason
|
|
4287
|
+
? { reason: err.attachReason || err.reason }
|
|
4288
|
+
: {}),
|
|
4289
|
+
...(err.attachOperation ? { operation: err.attachOperation } : {}),
|
|
4276
4290
|
})
|
|
4277
4291
|
}
|
|
4278
4292
|
return
|
package/src/usage.mjs
CHANGED
|
@@ -1366,6 +1366,67 @@ export async function findCodexRolloutFile(threadId, { lookbackDays = 9, now = D
|
|
|
1366
1366
|
return scanned
|
|
1367
1367
|
}
|
|
1368
1368
|
|
|
1369
|
+
/**
|
|
1370
|
+
* threadId の rollout ファイルの存在を **tri-state** で厳格判定する
|
|
1371
|
+
* (Cockpit Stage4 caller レビュー major #4)。`findCodexRolloutFile` は全 readdir エラーを
|
|
1372
|
+
* 握って null(不在扱い)にするため、EACCES/EIO 等の「走査できなかった」を「存在しない」と
|
|
1373
|
+
* 誤認し、有効な pointer を compare-delete してしまう。ここでは:
|
|
1374
|
+
* - "present": rollout ファイルを実際に見つけた。
|
|
1375
|
+
* - "absent" : 全走査が完了し、readdir 失敗は ENOENT(=そのシャードがまだ無い)だけで、
|
|
1376
|
+
* ファイルが無かった。→ 恒久不在と判断してよい (thread_not_found)。
|
|
1377
|
+
* - "unknown": 途中の readdir が ENOENT 以外 (EACCES/EIO 等) で失敗した / base 自体が読めない。
|
|
1378
|
+
* → 走査不能なので「不在」と断定しない (pointer を保持する)。
|
|
1379
|
+
* @returns {Promise<"present"|"absent"|"unknown">}
|
|
1380
|
+
*/
|
|
1381
|
+
export async function codexRolloutPresence(threadId) {
|
|
1382
|
+
if (!threadId) return "unknown"
|
|
1383
|
+
// メモ命中で present 即返し(findCodexRolloutFile と同じメモを共有)。
|
|
1384
|
+
const memoed = _codexRolloutPathMemo.get(threadId)
|
|
1385
|
+
if (memoed) {
|
|
1386
|
+
try {
|
|
1387
|
+
await fs.stat(memoed)
|
|
1388
|
+
return "present"
|
|
1389
|
+
} catch {
|
|
1390
|
+
_codexRolloutPathMemo.delete(threadId)
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
const base = codexSessionsDir()
|
|
1394
|
+
const suffix = `-${threadId}.jsonl`
|
|
1395
|
+
let sawUnknownError = false
|
|
1396
|
+
const readdirSafe = async (dir) => {
|
|
1397
|
+
try {
|
|
1398
|
+
return await fs.readdir(dir)
|
|
1399
|
+
} catch (err) {
|
|
1400
|
+
// ENOENT = そのディレクトリがまだ無い(正常な「そこには無い」)。それ以外は走査不能。
|
|
1401
|
+
if (err?.code !== "ENOENT") sawUnknownError = true
|
|
1402
|
+
return null
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
const years = await readdirSafe(base)
|
|
1406
|
+
if (years !== null) {
|
|
1407
|
+
for (const y of years.filter((s) => /^\d{4}$/.test(s)).sort().reverse()) {
|
|
1408
|
+
const months = await readdirSafe(path.join(base, y))
|
|
1409
|
+
if (!months) continue
|
|
1410
|
+
for (const m of months.sort().reverse()) {
|
|
1411
|
+
const days = await readdirSafe(path.join(base, y, m))
|
|
1412
|
+
if (!days) continue
|
|
1413
|
+
for (const d of days.sort().reverse()) {
|
|
1414
|
+
const dDir = path.join(base, y, m, d)
|
|
1415
|
+
const files = await readdirSafe(dDir)
|
|
1416
|
+
if (!files) continue
|
|
1417
|
+
const match = files.find((f) => f.endsWith(suffix))
|
|
1418
|
+
if (match) {
|
|
1419
|
+
_codexRolloutPathMemo.set(threadId, path.join(dDir, match))
|
|
1420
|
+
return "present"
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
// ファイルは見つからなかった。走査不能エラーがあったなら「不在」と断定しない。
|
|
1427
|
+
return sawUnknownError ? "unknown" : "absent"
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1369
1430
|
/** P5(perf): rollout ファイルの token_count 抽出結果を mtime+size でメモ化。 */
|
|
1370
1431
|
const _codexTokenCountMemo = new Map() // fp → { mtimeMs, size, result }
|
|
1371
1432
|
|