@cocorograph/hub-agent 0.8.13 → 0.8.15
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/claude-stream-bridge.mjs +33 -4
- package/src/codex-stream-bridge.mjs +150 -2
package/package.json
CHANGED
|
@@ -1408,11 +1408,20 @@ export class ClaudeStreamSession {
|
|
|
1408
1408
|
* メッセージ本文。drain 由来の emit でのみ渡す。frontend はこれを user バブルへ
|
|
1409
1409
|
* 昇格させる。キャンセル / 追加由来の emit では空 (昇格させない)。これにより
|
|
1410
1410
|
* 「先頭の pending をキャンセルした」のを「実行開始した」と誤認しなくなる (0.6.26)。
|
|
1411
|
-
* @param {{force?: boolean}} [opts]
|
|
1412
|
-
* emit する。再アタッチ時のキュー
|
|
1413
|
-
*
|
|
1411
|
+
* @param {{force?: boolean, startedIds?: (string|null)[], discarded?: string[]}} [opts]
|
|
1412
|
+
* force=true のとき署名重複チェックを無視して必ず emit する。再アタッチ時のキュー
|
|
1413
|
+
* snapshot 再送 (キュー再表示バグ修正, 0.6.30) に使う。署名が前回と同一でも browser が
|
|
1414
|
+
* ライブ配信を取りこぼしている可能性があるため。
|
|
1415
|
+
* discarded は「実行せずに捨てた input_id」(close 時の pending 破棄)。browser はこれを
|
|
1416
|
+
* 受けてチップを消さず未配信印を付ける (無言削除の根治, 2026-07-31)。 */
|
|
1414
1417
|
_emitQueueState(started = [], opts = undefined) {
|
|
1415
1418
|
const force = opts?.force === true
|
|
1419
|
+
// 無言削除の根治 (2026-07-31): 実行せずに捨てた input_id。空配列は載せない
|
|
1420
|
+
// (旧 browser との後方互換 = 未知フィールドは無視されるが、無駄なノイズも出さない)。
|
|
1421
|
+
const discarded =
|
|
1422
|
+
Array.isArray(opts?.discarded) && opts.discarded.length > 0
|
|
1423
|
+
? [...opts.discarded]
|
|
1424
|
+
: undefined
|
|
1416
1425
|
// 実行開始した項目の input_id (started と parallel。T06603 cross-review major#2)。
|
|
1417
1426
|
// 本文テキストだけだと、同一本文を連投したときに 2 通目のユーザー発言バブルが
|
|
1418
1427
|
// 「既に同じ本文のバブルがある」と誤判定されて永久に出ない。id で突合させる。
|
|
@@ -1446,6 +1455,8 @@ export class ClaudeStreamSession {
|
|
|
1446
1455
|
// items は id 付きでキャンセルボタンの対象特定に使う (0.6.26)。
|
|
1447
1456
|
this.onEvent?.({
|
|
1448
1457
|
...this._buildQueueSnapshot(),
|
|
1458
|
+
// 実行せずに捨てた id (close 時の pending 破棄)。載る時だけキーを出す。
|
|
1459
|
+
...(discarded ? { discarded } : {}),
|
|
1449
1460
|
started,
|
|
1450
1461
|
// started と parallel。連携注入なら 'peer'|'orchestrator'、通常は null。
|
|
1451
1462
|
startedSources,
|
|
@@ -2105,12 +2116,30 @@ export class ClaudeStreamSession {
|
|
|
2105
2116
|
close() {
|
|
2106
2117
|
this._closed = true
|
|
2107
2118
|
// 改修3: 未処理の pending を破棄 (セッション終了後に drain しないため)。
|
|
2119
|
+
const discardedIds = this._pendingMessages
|
|
2120
|
+
.map((m) => m.id)
|
|
2121
|
+
.filter((id) => typeof id === "string" && id)
|
|
2108
2122
|
const hadPending = this._pendingMessages.length > 0
|
|
2109
2123
|
this._pendingMessages = []
|
|
2124
|
+
// 無言削除の根治 (2026-07-31): 破棄した id を **受理済みリングから外す**。
|
|
2125
|
+
// browser の 2 軸判定は「キューに無い + 受理済み = 実行完了」なので、破棄した id を
|
|
2126
|
+
// accepted に残したまま空 snapshot を送ると、**実行していない送信を実行完了と誤認して
|
|
2127
|
+
// チップを無言削除**する (ユーザーが送った本文が痕跡なく消える)。accepted から外し、
|
|
2128
|
+
// かつ discarded として明示して「捨てた」ことを browser に伝える。
|
|
2129
|
+
for (const id of discardedIds) {
|
|
2130
|
+
const at = this._acceptedInputIds.indexOf(id)
|
|
2131
|
+
if (at !== -1) this._acceptedInputIds.splice(at, 1)
|
|
2132
|
+
}
|
|
2110
2133
|
// T06603 (2026-07-25): 破棄したことを必ず通知する。従来は無言で捨てていたため、
|
|
2111
2134
|
// 「セッションが畳まれた後もチップだけが残る」表示が残留していた。破棄は状態変化なので
|
|
2112
2135
|
// force で貫通させる (started=[] なのでバブル昇格は起きない)。
|
|
2113
|
-
if (hadPending)
|
|
2136
|
+
if (hadPending) {
|
|
2137
|
+
this.logger?.info(
|
|
2138
|
+
{ stream_id: this.stream_id, discarded: discardedIds.length },
|
|
2139
|
+
"pending messages discarded on close",
|
|
2140
|
+
)
|
|
2141
|
+
this._emitQueueState([], { force: true, discarded: discardedIds })
|
|
2142
|
+
}
|
|
2114
2143
|
// 常駐 query は input キューを閉じて generator を終わらせ query を完走させる。
|
|
2115
2144
|
if (this._inputQueue) this._inputQueue.close()
|
|
2116
2145
|
this.abortTurn()
|
|
@@ -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
|