@cocorograph/hub-agent 0.8.12 → 0.8.14
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/bin/hub-agent.mjs +6 -0
- package/package.json +1 -1
- package/src/claude-stream-bridge.mjs +81 -7
- package/src/codex-stream-bridge.mjs +150 -2
- package/src/sidebar-message-snapshot.mjs +14 -14
package/bin/hub-agent.mjs
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* - stop : daemon 停止 (Sprint J で launchd / systemd と併用)
|
|
12
12
|
* - plugins list : プラグイン一覧
|
|
13
13
|
*/
|
|
14
|
+
// R3(d): libuv スレッドプールを拡張する。サイドバー snapshot の全セッション jsonl 読み込みが、
|
|
15
|
+
// attach / history の fs と既定 4 スレッドのプールを奪い合って切替が体感で遅くなる。libuv は
|
|
16
|
+
// プール初回利用時に UV_THREADPOOL_SIZE を読むため、いかなる fs/dns/crypto 非同期処理より前
|
|
17
|
+
// (= プロセス最初期・import より前) に設定する。既に設定済みなら運用者の指定を尊重する。
|
|
18
|
+
if (!process.env.UV_THREADPOOL_SIZE) process.env.UV_THREADPOOL_SIZE = "16"
|
|
19
|
+
|
|
14
20
|
import { readFileSync } from "node:fs"
|
|
15
21
|
import { fileURLToPath } from "node:url"
|
|
16
22
|
import path from "node:path"
|
package/package.json
CHANGED
|
@@ -1720,6 +1720,12 @@ export class ClaudeStreamSession {
|
|
|
1720
1720
|
// 正規の中断は interrupt() だが、control request が失敗/無応答のときに generator を畳む
|
|
1721
1721
|
// 最終手段が無いと、close()/shutdown() で CLI 子プロセスが残留する。
|
|
1722
1722
|
this._residentAbortController = new AbortController()
|
|
1723
|
+
// R1(a): この run の AbortController を捕捉する。finally で「常駐 generator が返却された理由」を
|
|
1724
|
+
// 判別するのに使う。我々が意図的に畳んだ (close / interrupt フォールバックの keep-alive
|
|
1725
|
+
// hard-abort) ときだけ signal.aborted=true になる。子プロセスが自然終了 (クラッシュ/異常終了)
|
|
1726
|
+
// したときは我々は abort していないので false。this.* 参照だと後続 run が再代入した別
|
|
1727
|
+
// controller を読んでしまうため、必ずこの run 固有のローカル参照で判定する。
|
|
1728
|
+
const myAbort = this._residentAbortController
|
|
1723
1729
|
const options = {
|
|
1724
1730
|
cwd: this.cwd,
|
|
1725
1731
|
env: siblingEnvFor(this.cwd),
|
|
@@ -1882,6 +1888,40 @@ export class ClaudeStreamSession {
|
|
|
1882
1888
|
next.ultracode === true,
|
|
1883
1889
|
)
|
|
1884
1890
|
this._emitQueueState([next.text], { startedIds: [next.id] })
|
|
1891
|
+
} else if (
|
|
1892
|
+
!this._closed &&
|
|
1893
|
+
!this._residentSendInFlight &&
|
|
1894
|
+
!myAbort?.signal?.aborted
|
|
1895
|
+
) {
|
|
1896
|
+
// R1(a) 孤児根絶: detach-reap でも pending 再起動でもなく、我々の意図的 fold
|
|
1897
|
+
// (close / interrupt フォールバックの keep-alive hard-abort = myAbort.signal.aborted)
|
|
1898
|
+
// でもないのに常駐 generator が返却された = 子プロセスが自然終了した (クラッシュ/異常終了)。
|
|
1899
|
+
// _residentSendInFlight 中 (ABA/FIFO: 起動済み query の送信が reconcile を await 中) は
|
|
1900
|
+
// await 復帰後の送信が fresh-start で回復させるため除外済み。
|
|
1901
|
+
if (this._detached) {
|
|
1902
|
+
// browser が離れた warm セッションで子が死亡 = 真の孤児。誰も見ておらず、死んだ query は
|
|
1903
|
+
// R1(b) で reattach 対象にもならないため warm 保持する意味が無い。即 close + onReap で
|
|
1904
|
+
// _liveBySession から evict し browser へ exit を通知する (既存 reap 経路と同じ冪等処理)。
|
|
1905
|
+
// 従来はこの経路が無く、warm 中に子が死ぬと _closed=false のまま索引に残って孤児化し、
|
|
1906
|
+
// 枠を占有し続け reattach が死んだ session を引き当てていた。
|
|
1907
|
+
try {
|
|
1908
|
+
this.close()
|
|
1909
|
+
} finally {
|
|
1910
|
+
this.onReap?.({ reason: "child-exited" })
|
|
1911
|
+
}
|
|
1912
|
+
} else {
|
|
1913
|
+
// まだ browser attach 中: 既存の「次 input で resume 付き再起動」recovery (改修4/B6) を
|
|
1914
|
+
// 壊さないため evict しない (即 evict すると同一 stream への継続入力が stream_missing で
|
|
1915
|
+
// 落ちる)。ただし終端シグナルが無いとサイドバーの処理中ドット / UI「停止中…」が固着する
|
|
1916
|
+
// ため、abort 経路と対称に onTurnSettled を出して即時解除する。次 input が来なければ
|
|
1917
|
+
// detach 時の softDetach idle-timer / warm-bounds が回収し、R1(b) が死んだ session への
|
|
1918
|
+
// reattach を防ぐ。
|
|
1919
|
+
try {
|
|
1920
|
+
this.onTurnSettled?.()
|
|
1921
|
+
} catch {
|
|
1922
|
+
/* ignore */
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1885
1925
|
}
|
|
1886
1926
|
// マスク (2026-06-23): 常駐 query 終了後 (= CLI が jsonl を掴んでいない) のこの地点で、
|
|
1887
1927
|
// closed 済みならオンディスク jsonl をマスクする。reap 経路は close() 内で実行済み。
|
|
@@ -2049,6 +2089,18 @@ export class ClaudeStreamSession {
|
|
|
2049
2089
|
return true
|
|
2050
2090
|
}
|
|
2051
2091
|
|
|
2092
|
+
/** R1(b): このセッションへ resume 再アタッチしてよいか。close 済み、または常駐 generator が
|
|
2093
|
+
* 返却済み (_residentQueryReturned) で再走行していない (_residentQuery=null) = 子プロセスが
|
|
2094
|
+
* 死亡した状態には reattach しない (attach 側が新規 spawn へフォールバックする)。R1(a) で
|
|
2095
|
+
* 死亡時は同期 close されるので通常は _closed で弾かれるが、close 到達前の窓や将来経路でも
|
|
2096
|
+
* 死んだ query へ再アタッチしない二重ガードとして効かせる。まだ一度も query を起動していない
|
|
2097
|
+
* idle セッション (_residentQueryReturned=false) は生存中なので reattach 可。 */
|
|
2098
|
+
_isReattachable() {
|
|
2099
|
+
if (this._closed) return false
|
|
2100
|
+
if (this._residentQueryReturned && !this._residentQuery) return false
|
|
2101
|
+
return true
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2052
2104
|
/** セッション終了。新規ターンを止め、実行中なら中断し、watcher も停止する。 */
|
|
2053
2105
|
close() {
|
|
2054
2106
|
this._closed = true
|
|
@@ -2261,7 +2313,9 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2261
2313
|
// attach しても引き当たらず、越境 reattach が原理的に起きない (session_id は本来
|
|
2262
2314
|
// ユニークだが異常系でも cwd を跨がない)。
|
|
2263
2315
|
const live = this._liveBySession.get(this._liveKeyFor(scwd, resumeSessionId))
|
|
2264
|
-
|
|
2316
|
+
// R1(b): !_closed だけでなく「常駐子プロセスが実生存しているか」も確認する。子が死んで
|
|
2317
|
+
// いれば reattach せず下の新規 spawn 経路へフォールバックし、死んだ session を引き当てない。
|
|
2318
|
+
if (live && live._isReattachable()) {
|
|
2265
2319
|
// 多端末共有 (T06542 で常時): 旧 stream_id を Map から消さない。同一セッションを複数端末が
|
|
2266
2320
|
// 購読する状態を作り、どの端末の input/permission/interrupt も解決できるようにする
|
|
2267
2321
|
// (旧端末は reattach.streamIds に足され、backend が session group へ fan-out する)。
|
|
@@ -2366,14 +2420,15 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2366
2420
|
error: err?.message || String(err),
|
|
2367
2421
|
})
|
|
2368
2422
|
},
|
|
2369
|
-
onReap: () => {
|
|
2370
|
-
//
|
|
2371
|
-
//
|
|
2423
|
+
onReap: (info) => {
|
|
2424
|
+
// ターン完走後の遅延クローズ、または R1(a) 子プロセス死亡時の即 evict。Map / 索引から
|
|
2425
|
+
// 撤去し exit を emit する。多端末共有では複数端末キーが Map に載っているのでまとめて外す。
|
|
2426
|
+
// reason は既定「detached-after-turn」。R1(a) は「child-exited」を渡して観測可能にする。
|
|
2372
2427
|
this._dropSessionMappings(session)
|
|
2373
2428
|
this.emit("exit", {
|
|
2374
2429
|
stream_id: session.stream_id,
|
|
2375
2430
|
code: 0,
|
|
2376
|
-
reason: "detached-after-turn",
|
|
2431
|
+
reason: info?.reason || "detached-after-turn",
|
|
2377
2432
|
session_id: session.sessionId,
|
|
2378
2433
|
})
|
|
2379
2434
|
},
|
|
@@ -3252,13 +3307,32 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3252
3307
|
return at
|
|
3253
3308
|
}
|
|
3254
3309
|
|
|
3310
|
+
/** R1(c): このセッションを現在ライブに購読している端末数 (sessions Map で自分を指す stream key 数)。
|
|
3311
|
+
* 正規の多端末共有 (T06542) では 1 つの session を複数 stream_id が指す。単端末なら 1、
|
|
3312
|
+
* soft-detach 済み warm なら 0。 */
|
|
3313
|
+
_liveTerminalCount(session) {
|
|
3314
|
+
if (!session) return 0
|
|
3315
|
+
let n = 0
|
|
3316
|
+
for (const sid of session.streamIds || []) {
|
|
3317
|
+
if (this.sessions.get(sid) === session) n += 1
|
|
3318
|
+
}
|
|
3319
|
+
return n
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3255
3322
|
/** 子⑤ round5 Finding 2 対応: warm 境界で reap してはいけない「in-flight」セッション。
|
|
3256
3323
|
* 走行中ターン(_busy)に加え、送信待ちの pending がある(現ターン完了時に drain される)
|
|
3257
|
-
* セッションも保護する(reap すると未処理入力が失われる)。
|
|
3324
|
+
* セッションも保護する(reap すると未処理入力が失われる)。
|
|
3325
|
+
* R1(c) 多ウィンドウ相互 reap 抑止: 2 端末以上が現在ライブ購読中の多端末共有セッション
|
|
3326
|
+
* (T06542) も保護する。同一 cwd で別 session_id を開いた新規 1 本を残すために、複数
|
|
3327
|
+
* ウィンドウが見ている共有セッションを per-cwd=1 / cwd-LRU が刈ってしまう事故を防ぐ
|
|
3328
|
+
* (単端末の重複セッション collapse は従来どおり働く: 単端末は count=1 で非保護)。 */
|
|
3258
3329
|
_isSessionProtected(session) {
|
|
3259
3330
|
if (!session) return false
|
|
3260
3331
|
if (session._busy) return true
|
|
3261
|
-
|
|
3332
|
+
if (Array.isArray(session._pendingMessages) && session._pendingMessages.length > 0) {
|
|
3333
|
+
return true
|
|
3334
|
+
}
|
|
3335
|
+
return this._liveTerminalCount(session) >= 2
|
|
3262
3336
|
}
|
|
3263
3337
|
|
|
3264
3338
|
/** 子⑤: warm 境界超過セッションを reap する(detach の softDetach onTimeout と同じ撤去処理)。 */
|
|
@@ -391,13 +391,59 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
391
391
|
if (this.sessions.has(stream_id)) {
|
|
392
392
|
throw new Error(`stream_id already attached: ${stream_id}`)
|
|
393
393
|
}
|
|
394
|
+
// 計測 (2026-07-31): attach 1 回 = 1 行の内訳ログ (`codex attach timing`)。
|
|
395
|
+
// frontend の attach_sent→ready_received は 1 つの数字でしか見えず、その中身が
|
|
396
|
+
// 「app-server の cold spawn + initialize」なのか「thread/resume の rollout 読み込み」なのか、
|
|
397
|
+
// そもそも **cold spawn だったのか warm reattach だったのか**すら判別できなかった。
|
|
398
|
+
// ここで mode / warm フラグと各フェーズの ms を **同じ 1 行**に載せる。attach 時のみ出すので
|
|
399
|
+
// ログ量は既存の "codex session attached" と同オーダー (ターン毎には出ない)。
|
|
400
|
+
const attachStartedAt = Date.now()
|
|
401
|
+
let lapMark = attachStartedAt
|
|
402
|
+
/** 直前のマークからの経過 ms を返し、基準点を進める。 */
|
|
403
|
+
const lap = () => {
|
|
404
|
+
const now = Date.now()
|
|
405
|
+
const delta = now - lapMark
|
|
406
|
+
lapMark = now
|
|
407
|
+
return delta
|
|
408
|
+
}
|
|
409
|
+
/** フェーズ別内訳。未実行フェーズは未設定のまま (ログでは null)。 */
|
|
410
|
+
const timing = {}
|
|
394
411
|
// #1: 同じ attach_id のライブ session があれば in-memory reattach を試みる。
|
|
395
412
|
if (attachId) {
|
|
396
413
|
const existing = this._findLiveSessionByAttachId(attachId)
|
|
397
414
|
if (existing && existing.stream_id !== stream_id) {
|
|
398
415
|
const r = await this._tryReattach(existing, stream_id)
|
|
399
|
-
|
|
416
|
+
timing.reattach_wait_ms = (timing.reattach_wait_ms ?? 0) + lap()
|
|
417
|
+
if (r.status === "reattached") {
|
|
418
|
+
this._logAttachTiming({
|
|
419
|
+
stream_id,
|
|
420
|
+
cwd,
|
|
421
|
+
thread_id: r.info.threadId || null,
|
|
422
|
+
mode: "reattach_attach_id",
|
|
423
|
+
warm: true,
|
|
424
|
+
ok: true,
|
|
425
|
+
resuming: true,
|
|
426
|
+
thread_op: null,
|
|
427
|
+
history_items: 0,
|
|
428
|
+
total_ms: Date.now() - attachStartedAt,
|
|
429
|
+
timing,
|
|
430
|
+
})
|
|
431
|
+
return r.info
|
|
432
|
+
}
|
|
400
433
|
if (r.status === "superseded") {
|
|
434
|
+
this._logAttachTiming({
|
|
435
|
+
stream_id,
|
|
436
|
+
cwd,
|
|
437
|
+
thread_id: null,
|
|
438
|
+
mode: "reattach_attach_id",
|
|
439
|
+
warm: true,
|
|
440
|
+
ok: false,
|
|
441
|
+
resuming: true,
|
|
442
|
+
thread_op: null,
|
|
443
|
+
failed_stage: "reattach_superseded",
|
|
444
|
+
total_ms: Date.now() - attachStartedAt,
|
|
445
|
+
timing,
|
|
446
|
+
})
|
|
401
447
|
// 別 reattach が勝って session を奪った (concurrent reconnect)。**通常経路へ落とさない**
|
|
402
448
|
// — 落とすと敗者が別 thread を thread/start してしまい I2 (fresh 最大1回) を再び破る /
|
|
403
449
|
// 同一 thread の app-server が複数化する (major #1 ラウンド5)。二重 thread 副作用を作らず
|
|
@@ -424,16 +470,45 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
424
470
|
const existing = this._findLiveSessionByCwd(cwd, resumeThreadId)
|
|
425
471
|
if (existing && existing.stream_id !== stream_id) {
|
|
426
472
|
const r = await this._tryReattach(existing, stream_id)
|
|
473
|
+
timing.reattach_wait_ms = (timing.reattach_wait_ms ?? 0) + lap()
|
|
427
474
|
if (r.status === "reattached") {
|
|
475
|
+
const history = Array.isArray(existing.historyItems) ? existing.historyItems.slice() : []
|
|
476
|
+
this._logAttachTiming({
|
|
477
|
+
stream_id,
|
|
478
|
+
cwd,
|
|
479
|
+
thread_id: r.info.threadId || null,
|
|
480
|
+
mode: "reattach_cwd",
|
|
481
|
+
warm: true,
|
|
482
|
+
ok: true,
|
|
483
|
+
resuming: true,
|
|
484
|
+
thread_op: null,
|
|
485
|
+
history_items: history.length,
|
|
486
|
+
history_truncated: existing.historyTruncated === true,
|
|
487
|
+
total_ms: Date.now() - attachStartedAt,
|
|
488
|
+
timing,
|
|
489
|
+
})
|
|
428
490
|
return {
|
|
429
491
|
...r.info,
|
|
430
492
|
// 案1: reattach は履歴を返さない設計 (r.info.history=[]) だが、cwd reattach は remount
|
|
431
493
|
// 由来で表示がリセットされ得るため、live セッションが保持する高忠実度履歴を返す。
|
|
432
|
-
history
|
|
494
|
+
history,
|
|
433
495
|
historyTruncated: existing.historyTruncated === true,
|
|
434
496
|
}
|
|
435
497
|
}
|
|
436
498
|
if (r.status === "superseded") {
|
|
499
|
+
this._logAttachTiming({
|
|
500
|
+
stream_id,
|
|
501
|
+
cwd,
|
|
502
|
+
thread_id: null,
|
|
503
|
+
mode: "reattach_cwd",
|
|
504
|
+
warm: true,
|
|
505
|
+
ok: false,
|
|
506
|
+
resuming: true,
|
|
507
|
+
thread_op: null,
|
|
508
|
+
failed_stage: "reattach_superseded",
|
|
509
|
+
total_ms: Date.now() - attachStartedAt,
|
|
510
|
+
timing,
|
|
511
|
+
})
|
|
437
512
|
const err = new Error("attach superseded by a concurrent reattach")
|
|
438
513
|
err.attachOperation = "reattach"
|
|
439
514
|
err.attachReason = "superseded"
|
|
@@ -549,6 +624,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
549
624
|
this.sessions.set(stream_id, session)
|
|
550
625
|
this._wireClient(session)
|
|
551
626
|
client.start()
|
|
627
|
+
// app-server の spawn は同期呼び出し (fork/exec のみ)。プロセス起動の実コストは
|
|
628
|
+
// 直後の initialize RPC 応答待ちに現れるので、spawn_ms と initialize_ms は必ずセットで読む。
|
|
629
|
+
timing.spawn_ms = lap()
|
|
552
630
|
|
|
553
631
|
const resuming = !!resumeThreadId
|
|
554
632
|
// #3: initialize と thread op を分離し、thread op に到達したかを記録する。initialize / config
|
|
@@ -560,6 +638,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
560
638
|
"codex app-server session initialization",
|
|
561
639
|
)
|
|
562
640
|
await client.request("initialize", { clientInfo: CLIENT_INFO })
|
|
641
|
+
// ここまでが「app-server プロセスが起動して JSON-RPC に応答できるようになるまで」。
|
|
642
|
+
timing.initialize_ms = lap()
|
|
563
643
|
// 公式 App Server handshake: initialize 応答後、同じ transport 上で initialized を
|
|
564
644
|
// 通知してから thread operation を開始する。現行 server の寛容動作に依存しない。
|
|
565
645
|
client.notify("initialized", {})
|
|
@@ -584,6 +664,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
584
664
|
const result = resuming
|
|
585
665
|
? await client.request("thread/resume", { ...startParams, threadId: resumeThreadId })
|
|
586
666
|
: await client.request("thread/start", startParams)
|
|
667
|
+
// thread/resume は app-server 側で rollout(JSONL) を読み直すため、resume の
|
|
668
|
+
// thread_op_ms が大きい = rollout 読み込みが支配的、という読み方になる。
|
|
669
|
+
timing.thread_op_ms = lap()
|
|
587
670
|
session.threadId = result?.thread?.id
|
|
588
671
|
// thread/start は一覧そのものを変更するため、pre-attach で得た短期キャッシュを破棄する。
|
|
589
672
|
// resume/reattach は既存 thread の再閲覧なのでキャッシュを維持し、ready 後の重複走査を防ぐ。
|
|
@@ -604,6 +687,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
604
687
|
const flat = resuming
|
|
605
688
|
? flattenThreadItems(result?.thread, this._historyLimits)
|
|
606
689
|
: { items: [], truncated: false, total: 0 }
|
|
690
|
+
// hub-agent 側の履歴整形コスト (巨大スレッドで効く)。thread_op_ms とは別勘定にする。
|
|
691
|
+
timing.history_flatten_ms = lap()
|
|
607
692
|
const history = flat.items
|
|
608
693
|
const historyTruncated = flat.truncated
|
|
609
694
|
// T06541 案1: cold attach で得た高忠実度履歴をセッションに保持する。以降ライブ item
|
|
@@ -643,6 +728,21 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
643
728
|
},
|
|
644
729
|
"codex session attached",
|
|
645
730
|
)
|
|
731
|
+
this._logAttachTiming({
|
|
732
|
+
stream_id,
|
|
733
|
+
cwd,
|
|
734
|
+
thread_id: session.threadId || null,
|
|
735
|
+
mode: resuming ? "cold_resume" : "cold_start",
|
|
736
|
+
warm: false,
|
|
737
|
+
ok: true,
|
|
738
|
+
resuming,
|
|
739
|
+
thread_op: resuming ? "thread/resume" : "thread/start",
|
|
740
|
+
history_items: history.length,
|
|
741
|
+
history_total: flat.total,
|
|
742
|
+
history_truncated: historyTruncated,
|
|
743
|
+
total_ms: Date.now() - attachStartedAt,
|
|
744
|
+
timing,
|
|
745
|
+
})
|
|
646
746
|
// 子⑤ (T06544): genuine attach で 1 本増えたので warm 境界(per-cwd=1 / N-cwd LRU)を即時
|
|
647
747
|
// 適用する(次の reconnect まで ~5s 待たず、同一 cwd の重複や cwd 数超過が居座るのを防ぐ)。
|
|
648
748
|
// 新セッションはたった今 touch 済み = 各判定で最新なので退避対象にならず、最も古い非 protected
|
|
@@ -661,12 +761,60 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
661
761
|
this.sessions.delete(stream_id)
|
|
662
762
|
client.stop()
|
|
663
763
|
settleReady(null) // reattach 待ちへ「失敗」を通知 (reject しない)
|
|
764
|
+
// 失敗も 1 行に落とす。**_classifyAttachFailure より前**に出す (分類は rollout の disk 走査を
|
|
765
|
+
// 伴うことがあり、その待ち時間を attach レイテンシに混ぜないため)。
|
|
766
|
+
this._logAttachTiming({
|
|
767
|
+
stream_id,
|
|
768
|
+
cwd,
|
|
769
|
+
thread_id: null,
|
|
770
|
+
mode: resuming ? "cold_resume" : "cold_start",
|
|
771
|
+
warm: false,
|
|
772
|
+
ok: false,
|
|
773
|
+
resuming,
|
|
774
|
+
thread_op: reachedThreadOp ? (resuming ? "thread/resume" : "thread/start") : null,
|
|
775
|
+
failed_stage: reachedThreadOp ? "thread_op" : "initialize",
|
|
776
|
+
total_ms: Date.now() - attachStartedAt,
|
|
777
|
+
timing,
|
|
778
|
+
})
|
|
664
779
|
// #3/#4: thread op に到達した失敗だけ分類する (initialize 失敗は分類しない)。
|
|
665
780
|
if (reachedThreadOp) await this._classifyAttachFailure(err, { resuming, resumeThreadId })
|
|
666
781
|
throw err
|
|
667
782
|
}
|
|
668
783
|
}
|
|
669
784
|
|
|
785
|
+
/**
|
|
786
|
+
* attach 1 回分の内訳を **1 行** の構造化ログに落とす (`msg="codex attach timing"`)。
|
|
787
|
+
*
|
|
788
|
+
* ## なぜ必要か (WHY)
|
|
789
|
+
* frontend の switch-metrics は `attach_sent → ready_received` を 1 つの数字でしか持たず
|
|
790
|
+
* (本番 p50 ~436ms)、その中身が app-server の cold spawn なのか thread/resume の rollout
|
|
791
|
+
* 読み込みなのか切り分けられなかった。さらに致命的なのは **warm 再利用 (in-memory reattach) と
|
|
792
|
+
* cold spawn を区別する材料がログのどこにも無かった**こと (frontend の temp=cold|warm は
|
|
793
|
+
* ブラウザ内キャッシュの命中/非命中であって hub-agent の warm 再利用とは無関係)。
|
|
794
|
+
*
|
|
795
|
+
* ## 読み方
|
|
796
|
+
* - `warm=true` / `mode=reattach_*` … app-server を立てていない (所要は `reattach_wait_ms` だけ)
|
|
797
|
+
* - `warm=false` / `mode=cold_*` … `spawn_ms`(同期 spawn) + `initialize_ms`(プロセス起動待ち)
|
|
798
|
+
* + `thread_op_ms`(thread/start|resume。resume は rollout 読み込み込み) + `history_flatten_ms`
|
|
799
|
+
*
|
|
800
|
+
* ログ量: attach 時のみ (ターン毎には出ない)。既存の "codex session attached" と同オーダー。
|
|
801
|
+
*/
|
|
802
|
+
_logAttachTiming(fields) {
|
|
803
|
+
const { timing = {}, ...rest } = fields
|
|
804
|
+
this.logger?.info?.(
|
|
805
|
+
{
|
|
806
|
+
...rest,
|
|
807
|
+
// 未実行フェーズは null (0ms と「通っていない」を混同させない)。
|
|
808
|
+
reattach_wait_ms: timing.reattach_wait_ms ?? null,
|
|
809
|
+
spawn_ms: timing.spawn_ms ?? null,
|
|
810
|
+
initialize_ms: timing.initialize_ms ?? null,
|
|
811
|
+
thread_op_ms: timing.thread_op_ms ?? null,
|
|
812
|
+
history_flatten_ms: timing.history_flatten_ms ?? null,
|
|
813
|
+
},
|
|
814
|
+
"codex attach timing",
|
|
815
|
+
)
|
|
816
|
+
}
|
|
817
|
+
|
|
670
818
|
/** attach_id に一致するライブ session を返す (in-memory reattach 用。無ければ null)。 */
|
|
671
819
|
_findLiveSessionByAttachId(attachId) {
|
|
672
820
|
if (!attachId) return null
|
|
@@ -182,7 +182,7 @@ async function findCodexRolloutFile(threadId, sessionsDir) {
|
|
|
182
182
|
return null
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
async function readCached(filePath, extract
|
|
185
|
+
async function readCached(filePath, extract) {
|
|
186
186
|
if (!filePath) return { user: "", assistant: "" }
|
|
187
187
|
let fileStat
|
|
188
188
|
try {
|
|
@@ -192,11 +192,15 @@ async function readCached(filePath, extract, { maxAgeMs = Infinity } = {}) {
|
|
|
192
192
|
}
|
|
193
193
|
const version = `${fileStat.mtimeMs}:${fileStat.size}:${fileStat.ctimeMs}`
|
|
194
194
|
const cached = textCache.get(filePath)
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
195
|
+
if (cached && cached.version === version) {
|
|
196
|
+
// mtime/size/ctime 未変化 = jsonl 本文不変。tail 読み + パースを丸ごとスキップし、
|
|
197
|
+
// 前回抽出済みテキストをそのまま返す (R3(a) mtime 差分キャッシュ)。stat 1 発だけで済む。
|
|
198
|
+
// 旧実装は maxAgeMs=2s の age ゲートで、8s 周期のサイドバー snapshot が毎回 (2s 超過で)
|
|
199
|
+
// 未変化ファイルまで再抽出し、attach/history の fs と libuv プールを奪い合っていた。
|
|
200
|
+
// LRU 順を保つため hot エントリを末尾へ入れ直す (最古から evict される側から救う)。
|
|
201
|
+
cached.checkedAt = Date.now()
|
|
202
|
+
textCache.delete(filePath)
|
|
203
|
+
textCache.set(filePath, cached)
|
|
200
204
|
return cached.texts
|
|
201
205
|
}
|
|
202
206
|
|
|
@@ -287,10 +291,8 @@ export async function readSidebarMessageSnapshot(
|
|
|
287
291
|
session_id: target.conversationId,
|
|
288
292
|
projectsRoot,
|
|
289
293
|
})
|
|
290
|
-
texts = await readCached(
|
|
291
|
-
|
|
292
|
-
(fp) => extractClaude(fp, { isInjected }),
|
|
293
|
-
{ maxAgeMs: 2_000 },
|
|
294
|
+
texts = await readCached(filePath, (fp) =>
|
|
295
|
+
extractClaude(fp, { isInjected }),
|
|
294
296
|
)
|
|
295
297
|
return {
|
|
296
298
|
session_name: target.sessionName,
|
|
@@ -309,10 +311,8 @@ export async function readSidebarMessageSnapshot(
|
|
|
309
311
|
const { sessionsDir, profileId } = await resolveCodexRoot(target.profileId)
|
|
310
312
|
if (!texts.user || !texts.assistant) {
|
|
311
313
|
const filePath = await findCodexRolloutFile(target.conversationId, sessionsDir)
|
|
312
|
-
const diskTexts = await readCached(
|
|
313
|
-
|
|
314
|
-
(fp) => extractCodex(fp, { isInjected }),
|
|
315
|
-
{ maxAgeMs: 2_000 },
|
|
314
|
+
const diskTexts = await readCached(filePath, (fp) =>
|
|
315
|
+
extractCodex(fp, { isInjected }),
|
|
316
316
|
)
|
|
317
317
|
texts = {
|
|
318
318
|
user: texts.user || diskTexts.user,
|