@cocorograph/hub-agent 0.8.0 → 0.8.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -85,6 +85,42 @@ const CHAT_RESIDENT_ENABLED = process.env.HUB_AGENT_CHAT_RESIDENT !== "0"
85
85
  const CHAT_RESIDENT_RESUME_ENABLED =
86
86
  process.env.HUB_AGENT_CHAT_RESIDENT_RESUME !== "0"
87
87
 
88
+ /** 受理済み input_id リングの上限 (T06603, 2026-07-25)。queue snapshot に載せて
89
+ * browser の「実行完了 vs 未配達」判定に使う。1 セッションの送信待ちは実運用で
90
+ * 数件〜十数件なので 256 あれば「表示中の楽観チップが評価対象から溢れる」ことは無い。
91
+ * 溢れた場合も誤って消すのではなく「未配達 (要再送)」側に倒れるため安全側。 */
92
+ const ACCEPTED_INPUT_ID_RING_MAX = 256
93
+
94
+ /** キュー snapshot の世代 (T06603, 2026-07-25)。
95
+ *
96
+ * 版番号 (seq) は **セッションオブジェクト単位**の連番なので、同一プロセス内でも
97
+ * session が作り直される (resume / 再 attach) と 1 に戻る。一方 backend の写しキャッシュと
98
+ * browser は単調な版を覚えているため、seq だけで比較すると新しい snapshot が永久に
99
+ * 「古い」と判定されて **表示が凍り付く**。そこで seq の上位に世代を置く。
100
+ *
101
+ * ⚠️ **この値が保証するのは「同一プロセス内」の順序だけ** (cross-review round2)。値は
102
+ * 壁時計 ms で、プロセス内では必ず前回より大きい値を返す (同一 ms の衝突回避 + 時計が
103
+ * 巻き戻っても +1 で単調) が、**プロセス再起動をまたぐ順序は保証しない** (NTP のステップ
104
+ * 補正・手動時刻変更・別ホスト間の時計差で逆転しうる)。
105
+ *
106
+ * そしてその保証は **不要** である (cross-review 3 巡目で設計確定)。browser は
107
+ * ① 適用する queue_state を own stream の authoritative コピーだけに限る
108
+ * ② attach (claude.ready) のたびに版番号の基準点を外す
109
+ * という 2 点により、版番号空間を **1 回の attach の中に閉じている**。agent が再起動すれば
110
+ * browser は必ず再 attach するので (agent.status online → reattachSameSession)、
111
+ * プロセスを跨いで版を比較する場面が存在しない。
112
+ *
113
+ * ゆえにここで壁時計以上の保証を作り込もうとしないこと。プロセス間の全順序を求めると
114
+ * 永続採番 (DB sequence) と session 所有権リースが必要になり、表示のために分散合意に近い
115
+ * 機構を抱えることになる (実際に版番号方式では「時計後退で永久 reject」「非権威 producer に
116
+ * よる cache 汚染」の穴が連続して見つかり、設計ごと撤回した)。 */
117
+ let _lastQueueGeneration = 0
118
+ function nextQueueGeneration() {
119
+ const now = Date.now()
120
+ _lastQueueGeneration = now > _lastQueueGeneration ? now : _lastQueueGeneration + 1
121
+ return _lastQueueGeneration
122
+ }
123
+
88
124
  /** MCP control 専用 query のアイドル破棄時間 (2026-06-03)。MCP サーバーは Claude Code
89
125
  * 設定 (アカウント単位) で全セッション共有のため、チャットセッションとは独立した
90
126
  * 「control 専用 query」を 1 本だけ遅延起動して状態取得・再認証・再接続を行う。
@@ -434,6 +470,24 @@ export class ClaudeStreamSession {
434
470
  /** 改修3: 直近 browser へ通知した queue 署名 (件数 + id 列)。変化時のみ emit する。
435
471
  * 空キューの署名 ("0:") で初期化し、空→空の冗長 emit を抑止する。 */
436
472
  this._lastEmittedQueueSig = "0:"
473
+ /** キュー snapshot の版番号 (T06603, 2026-07-25)。emit するたびに単調増加する。
474
+ * browser は「受け取った snapshot が手元より新しいときだけ適用する」ことで、
475
+ * ①遅延した relay / backend キャッシュの古い snapshot による巻き戻し
476
+ * ②stream group と session group の二重配送
477
+ * を無害化できる。**欠落の検知には使わない** (snapshot は全状態を含むので、
478
+ * 落ちた版があっても次に届いた版を適用すれば正しくなる)。落ちたまま次が来ない
479
+ * ケースの救済は browser 側の pull (claude.queue.request) が担う。 */
480
+ this._queueStateSeq = 0
481
+ /** この session オブジェクトの世代 (T06603)。上記 nextQueueGeneration の説明を参照。
482
+ * **同一プロセス内の順序のみ**を保証する (プロセス間は backend の接続フェンスが担う)。 */
483
+ this._queueGeneration = nextQueueGeneration()
484
+ /** 配達確認の再表明用 (T06603): この session が受理 (キュー投入 or 直接配送) した
485
+ * input_id の直近リング。`claude.input.ack` は一度きりのユニキャストで、WS 切断が
486
+ * 30s を超えると ws-client の ctrl バッファから破棄され二度と再送されない。その結果
487
+ * browser は「実際には届いて実行されたのに未配達扱い」で送信待ちチップを固着させる。
488
+ * snapshot にこのリングを載せることで、ack を取りこぼした browser も
489
+ * 「キューに無い + 受理済み = 実行完了」と判定でき、pull だけで自己回復できる。 */
490
+ this._acceptedInputIds = []
437
491
 
438
492
  /** 連携カード種別タグ (Phase 2, 2026-07-23): ask-sibling/orchestrate 等の **別セッションからの
439
493
  * 注入** を、受け手画面で「青い自分バブル」ではなく専用の連携依頼カードで表示させるため、
@@ -950,6 +1004,19 @@ export class ClaudeStreamSession {
950
1004
  * `accepted:false` のときは呼び出し元 (ClaudeStreamBridge.input) が nack を組み立てる。
951
1005
  */
952
1006
  async sendMessage(message, opts = {}) {
1007
+ const result = await this._sendMessageInner(message, opts)
1008
+ // 配達確認の再表明 (T06603): 受理 (= ack を返す) の記録を **1 箇所に集約**する。
1009
+ // 投入経路は busy キュー / resident 直行 / fresh start / per-message と 4 通りあり、
1010
+ // 各経路に散らすと新経路追加のたびに記録漏れが起き、browser が「未配達」と誤判定して
1011
+ // チップを固着させる。ack を返す条件 (accepted) と同じ場所で 1 回だけ記録する。
1012
+ const inputId =
1013
+ typeof opts?.input_id === "string" && opts.input_id ? opts.input_id : null
1014
+ if (inputId && result?.accepted) this._recordAcceptedInput(inputId)
1015
+ return result
1016
+ }
1017
+
1018
+ /** sendMessage 本体 (受理記録は呼び出し元の sendMessage が funnel で行う)。 */
1019
+ async _sendMessageInner(message, opts = {}) {
953
1020
  if (this._closed) return { accepted: false, reason: "queue_dead" }
954
1021
  const prompt = extractPromptText(message)
955
1022
  if (!prompt) return { accepted: false, reason: "empty_message" }
@@ -1227,7 +1294,7 @@ export class ClaudeStreamSession {
1227
1294
  return
1228
1295
  }
1229
1296
  const next = this._pendingMessages.shift()
1230
- this._emitQueueState([next.text])
1297
+ this._emitQueueState([next.text], { startedIds: [next.id] })
1231
1298
  this._runPerMessage(next.text, { ultracode: next.ultracode === true }).catch(
1232
1299
  (err) => {
1233
1300
  this.logger?.error(
@@ -1245,10 +1312,37 @@ export class ClaudeStreamSession {
1245
1312
  // (楽観チップの id と一致させ、確認前キャンセルを可能にする)。無ければ従来の qN。
1246
1313
  const id =
1247
1314
  typeof inputId === "string" && inputId ? inputId : `q${++this._queueSeq}`
1315
+ // 二重投入ガード (T06603, 2026-07-25): 同一 input_id が既にキューに居るなら積まない。
1316
+ // main.mjs の in-flight dedup (claudeInflightByInputId) で通常は到達しないが、
1317
+ // 「in-flight レジストリから外れた直後 (settle 済み・seen 登録前) の再送」等の窓では
1318
+ // ここが最後の砦になる。id 重複を許すと ①同じ本文が 2 回実行される
1319
+ // ②drain 後も同 id の項目がキューに残り送信待ちチップが消えない、の二重障害になる。
1320
+ if (this._pendingMessages.some((m) => m.id === id)) {
1321
+ this.logger?.info(
1322
+ { stream_id: this.stream_id, id },
1323
+ "duplicate input_id already queued, skipped",
1324
+ )
1325
+ return id
1326
+ }
1248
1327
  this._pendingMessages.push({ id, text: prompt, ultracode: ultracode === true })
1249
1328
  return id
1250
1329
  }
1251
1330
 
1331
+ /** 配達確認の再表明 (T06603): 受理済み input_id をリングへ記録する。
1332
+ * snapshot の `accepted` として browser へ載せ、ack 喪失時の自己回復に使う。 */
1333
+ _recordAcceptedInput(inputId) {
1334
+ if (typeof inputId !== "string" || !inputId) return
1335
+ const at = this._acceptedInputIds.indexOf(inputId)
1336
+ if (at !== -1) this._acceptedInputIds.splice(at, 1)
1337
+ this._acceptedInputIds.push(inputId)
1338
+ if (this._acceptedInputIds.length > ACCEPTED_INPUT_ID_RING_MAX) {
1339
+ this._acceptedInputIds.splice(
1340
+ 0,
1341
+ this._acceptedInputIds.length - ACCEPTED_INPUT_ID_RING_MAX,
1342
+ )
1343
+ }
1344
+ }
1345
+
1252
1346
  /** キャンセル機能 (0.6.26): browser から id 指定で送信待ちメッセージを 1 件取り消す。
1253
1347
  * 実行開始済み (既に drain された) メッセージは pending に残っていないので no-op になる。
1254
1348
  * 削除に成功したら queue_state を再 emit して送信待ちチップを更新する。 */
@@ -1269,9 +1363,30 @@ export class ClaudeStreamSession {
1269
1363
  { stream_id: this.stream_id, id, queued: this._pendingMessages.length },
1270
1364
  "queued message canceled",
1271
1365
  )
1272
- this._emitQueueState()
1273
1366
  }
1274
- return removed
1367
+ // T06603 cross-review 5 巡目: **「消えた」だけでは取消成功と実行開始済みを区別できない**。
1368
+ // drain の started フレームが落ちていると、browser は「キューに無い + 受理済み」で
1369
+ // 実行完了と判定してチップを消すが、ユーザーから見ると「取り消せた」ようにしか見えず、
1370
+ // しかも started 欠落で発言バブルも出ていない。ここで結末を明示する。
1371
+ // - pending から除去できた → cancelled
1372
+ // - pending に無い + 受理済み → already_started (もう走っている)
1373
+ // - pending に無い + 未受理 → cancelled (先着キャンセルガードを張ったので、
1374
+ // 後から届く同 id の input は破棄される)
1375
+ const outcome = removed
1376
+ ? "cancelled"
1377
+ : this._acceptedInputIds.includes(id)
1378
+ ? "already_started"
1379
+ : "cancelled"
1380
+ // ⚠️ T06603 cross-review 6 巡目: **snapshot をここで emit しない**。
1381
+ // 従来はここで force 再表明していたが、そのフレームは outcome 応答より先に browser へ
1382
+ // 届く (backend は agent の到着順に relay する)。先に届いた空 snapshot が「キューに無い
1383
+ // + 受理済み = 実行完了」の判定でチップを消してしまい、後続の outcome は対象不在で
1384
+ // no-op になる → already_started のバブル補償が発火しない。
1385
+ // 呼び出し元が outcome を送ってから flush() を呼ぶことで、配送順を確定させる。
1386
+ return {
1387
+ outcome,
1388
+ flush: () => this._emitQueueState([], { force: true }),
1389
+ }
1275
1390
  }
1276
1391
 
1277
1392
  /** 改修3: pending キューの現状を browser へ通知する (送信待ちチップ表示用)。
@@ -1285,6 +1400,12 @@ export class ClaudeStreamSession {
1285
1400
  * 署名が前回と同一でも browser がライブ配信を取りこぼしている可能性があるため。 */
1286
1401
  _emitQueueState(started = [], opts = undefined) {
1287
1402
  const force = opts?.force === true
1403
+ // 実行開始した項目の input_id (started と parallel。T06603 cross-review major#2)。
1404
+ // 本文テキストだけだと、同一本文を連投したときに 2 通目のユーザー発言バブルが
1405
+ // 「既に同じ本文のバブルがある」と誤判定されて永久に出ない。id で突合させる。
1406
+ const startedIds = Array.isArray(opts?.startedIds)
1407
+ ? opts.startedIds
1408
+ : started.map(() => null)
1288
1409
  const count = this._pendingMessages.length
1289
1410
  // 署名 = 件数 + id 列。件数が同じでもキャンセルで中身が変われば通知する。
1290
1411
  const sig = `${count}:${this._pendingMessages.map((m) => m.id).join(",")}`
@@ -1311,19 +1432,47 @@ export class ClaudeStreamSession {
1311
1432
  // 昇格させるため、ここで切り詰めると本文が欠ける。チップの省略表示は CSS 側で行う。
1312
1433
  // items は id 付きでキャンセルボタンの対象特定に使う (0.6.26)。
1313
1434
  this.onEvent?.({
1314
- type: "queue_state",
1315
- pending: count,
1316
- messages: this._pendingMessages.map((m) => m.text),
1317
- items: this._pendingMessages.map((m) => ({ id: m.id, text: m.text })),
1435
+ ...this._buildQueueSnapshot(),
1318
1436
  started,
1319
1437
  // started と parallel。連携注入なら 'peer'|'orchestrator'、通常は null。
1320
1438
  startedSources,
1439
+ // started と parallel。キュー由来なら input_id、注入等で id が無ければ null。
1440
+ startedIds,
1321
1441
  })
1322
1442
  } catch {
1323
1443
  /* ignore */
1324
1444
  }
1325
1445
  }
1326
1446
 
1447
+ /** キュー snapshot (T06603, 2026-07-25): 現在の権威キュー状態を 1 個のオブジェクトに
1448
+ * まとめる。ライブ配信 (_emitQueueState) と pull 応答 (claude.queue.request) の
1449
+ * **両方がこの 1 関数を通る**ため、経路によって形が食い違うことがない。
1450
+ * 版番号 (seq) はここで採番する = 「browser へ出す状態が 1 つ進んだ」の唯一の定義。 */
1451
+ _buildQueueSnapshot() {
1452
+ this._queueStateSeq += 1
1453
+ return {
1454
+ type: "queue_state",
1455
+ // 世代 (T06603 cross-review blocker): 再起動 / resume で seq が 1 に戻っても、
1456
+ // 受け手が「古い」と誤判定しないよう上位キーとして必ず載せる。
1457
+ generation: this._queueGeneration,
1458
+ seq: this._queueStateSeq,
1459
+ pending: this._pendingMessages.length,
1460
+ messages: this._pendingMessages.map((m) => m.text),
1461
+ items: this._pendingMessages.map((m) => ({ id: m.id, text: m.text })),
1462
+ // 受理済み input_id (直近リング)。browser は「キューに無い + ここに有る = 実行完了」
1463
+ // と判定して楽観チップを消し、「キューに無い + ここにも無い = 未配達」なら
1464
+ // 消さずに再送導線を出す (無言削除の回帰を作らないための 2 軸判定)。
1465
+ accepted: [...this._acceptedInputIds],
1466
+ }
1467
+ }
1468
+
1469
+ /** pull 型再同期 (T06603): 現在のキュー snapshot を返す (emit はしない)。
1470
+ * browser の claude.queue.request に対して claude.queue.response で返す。
1471
+ * 純粋な読み取りなので Read-plane (並行レーン) から呼んでよい。 */
1472
+ queueSnapshot() {
1473
+ return this._buildQueueSnapshot()
1474
+ }
1475
+
1327
1476
  /** 連携カード種別 (Phase 2, cross-review major#1): **idle 直接送信経路**用。busy→queue→drain は
1328
1477
  * _emitQueueState([text]) を通るが、idle 直接実行 (resident fresh start / per-message 即時) は
1329
1478
  * started を出さないため、注入の source が startedSources に載らずカード化されない。ここで
@@ -1527,7 +1676,11 @@ export class ClaudeStreamSession {
1527
1676
  // codex-review minor: session が close 中 (outcome==="closed") は next を配送していない。
1528
1677
  // started 通知 (queue_state に [next.text] を載せる) を出すと未配送メッセージが「開始済み」に
1529
1678
  // 見えるため抑止する。closed は終端状態なので pending へ戻さず破棄でよい (session 撤去中)。
1530
- if (outcome !== "closed") this._emitQueueState([next.text])
1679
+ // T06603 (2026-07-25): ただし破棄したこと自体は通知する (started 無しの force 再表明)。
1680
+ // 従来は shift 済みなのに emit しないため、当該項目のチップが browser に残り続けていた。
1681
+ if (outcome !== "closed")
1682
+ this._emitQueueState([next.text], { startedIds: [next.id] })
1683
+ else this._emitQueueState([], { force: true })
1531
1684
  } catch (err) {
1532
1685
  this._busy = false
1533
1686
  this._pendingMessages.unshift(next)
@@ -1709,7 +1862,7 @@ export class ClaudeStreamSession {
1709
1862
  toSDKUserMessage(next.text),
1710
1863
  next.ultracode === true,
1711
1864
  )
1712
- this._emitQueueState([next.text])
1865
+ this._emitQueueState([next.text], { startedIds: [next.id] })
1713
1866
  }
1714
1867
  // マスク (2026-06-23): 常駐 query 終了後 (= CLI が jsonl を掴んでいない) のこの地点で、
1715
1868
  // closed 済みならオンディスク jsonl をマスクする。reap 経路は close() 内で実行済み。
@@ -1868,7 +2021,12 @@ export class ClaudeStreamSession {
1868
2021
  close() {
1869
2022
  this._closed = true
1870
2023
  // 改修3: 未処理の pending を破棄 (セッション終了後に drain しないため)。
2024
+ const hadPending = this._pendingMessages.length > 0
1871
2025
  this._pendingMessages = []
2026
+ // T06603 (2026-07-25): 破棄したことを必ず通知する。従来は無言で捨てていたため、
2027
+ // 「セッションが畳まれた後もチップだけが残る」表示が残留していた。破棄は状態変化なので
2028
+ // force で貫通させる (started=[] なのでバブル昇格は起きない)。
2029
+ if (hadPending) this._emitQueueState([], { force: true })
1872
2030
  // 常駐 query は input キューを閉じて generator を終わらせ query を完走させる。
1873
2031
  if (this._inputQueue) this._inputQueue.close()
1874
2032
  this.abortTurn()
@@ -2932,11 +3090,26 @@ export class ClaudeStreamBridge extends EventEmitter {
2932
3090
  const s = this.sessions.get(stream_id)
2933
3091
  if (!s) {
2934
3092
  this.logger?.warn({ stream_id, id }, "claude.queue.cancel but stream missing")
2935
- return false
3093
+ // T06603 cross-review 4/5 巡目: **結末を必ず browser へ返す**。browser はキャンセル
3094
+ // 要求時にチップを消さず「取消中」の印を付けて権威の確認を待つ設計なので、
3095
+ // 無応答だと印が外れる契機が無く「取消中」のまま固着する。
3096
+ return { outcome: "stream_missing", flush: () => {} }
2936
3097
  }
2937
3098
  return s.cancelQueued(id)
2938
3099
  }
2939
3100
 
3101
+ /** pull 型再同期 (T06603, 2026-07-25): browser の claude.queue.request に答えるため、
3102
+ * 現在の権威キュー snapshot を返す。stream が無ければ null (browser 側は
3103
+ * 「まだ attach していない」として扱い、ready 後に引き直す)。 */
3104
+ queueSnapshot({ stream_id }) {
3105
+ const s = this.sessions.get(stream_id)
3106
+ if (!s) {
3107
+ this.logger?.warn({ stream_id }, "claude.queue.request but stream missing")
3108
+ return null
3109
+ }
3110
+ return { session_id: s.sessionId || null, snapshot: s.queueSnapshot() }
3111
+ }
3112
+
2940
3113
  /**
2941
3114
  * セッション停止 (graceful)。実行中ターンは中断せず完走させ、完走後に
2942
3115
  * onReap で Map から撤去する。アイドルなら即時撤去。
@@ -0,0 +1,177 @@
1
+ import { createHash } from "node:crypto"
2
+ import { open, stat } from "node:fs/promises"
3
+
4
+ import { findCodexRolloutFile } from "./usage.mjs"
5
+
6
+ const MAX_AUDIT_BYTES = 4 * 1024 * 1024
7
+
8
+ function sha256(text) {
9
+ return createHash("sha256").update(String(text ?? ""), "utf8").digest("hex")
10
+ }
11
+
12
+ function userMessageText(record) {
13
+ if (record?.type !== "response_item") return null
14
+ const payload = record.payload
15
+ if (payload?.type !== "message" || payload?.role !== "user") return null
16
+ if (typeof payload.content === "string") return payload.content
17
+ if (!Array.isArray(payload.content)) return null
18
+ return payload.content
19
+ .map((part) =>
20
+ part && typeof part === "object" && typeof part.text === "string" ? part.text : "",
21
+ )
22
+ .join("")
23
+ }
24
+
25
+ /**
26
+ * 注入前の rollout 位置を記録する。rollout がまだ無い fresh thread も offset=0 として扱い、
27
+ * verify 時に同じ threadId から再解決する。rollout は二次監査であり、失敗しても配送を止めない。
28
+ */
29
+ export async function snapshotCodexDeliveryAudit(
30
+ threadId,
31
+ text,
32
+ { findRolloutFile = findCodexRolloutFile } = {},
33
+ ) {
34
+ const contentHash = sha256(text)
35
+ let filePath = null
36
+ let offset = 0
37
+ let snapshotReason = null
38
+ try {
39
+ filePath = await findRolloutFile(threadId)
40
+ if (filePath) offset = (await stat(filePath)).size
41
+ else snapshotReason = "rollout_not_found_before_dispatch"
42
+ } catch (err) {
43
+ snapshotReason = `snapshot_error:${err instanceof Error ? err.message : String(err)}`
44
+ filePath = null
45
+ offset = 0
46
+ }
47
+ return {
48
+ threadId,
49
+ contentHash,
50
+ filePath,
51
+ offset,
52
+ snapshotReason,
53
+ dispatchedAtMs: Date.now(),
54
+ }
55
+ }
56
+
57
+ async function scanAuditRange(audit) {
58
+ const filePath = audit.filePath
59
+ if (!filePath) return { durable: false, reason: "audit_file_path_missing" }
60
+ const info = await stat(filePath)
61
+ if (info.size <= audit.offset) {
62
+ return { durable: false, reason: "rollout_not_advanced", filePath }
63
+ }
64
+
65
+ // 注入前 offset からだけ読む。極端な flush 増加時は末尾 4MiB に制限し、先頭の半端行を捨てる。
66
+ const desiredStart = Math.min(audit.offset, info.size)
67
+ const start = Math.max(desiredStart, info.size - MAX_AUDIT_BYTES)
68
+ const length = info.size - start
69
+ const fh = await open(filePath, "r")
70
+ let text
71
+ try {
72
+ const buffer = Buffer.alloc(length)
73
+ const { bytesRead } = await fh.read(buffer, 0, length, start)
74
+ text = buffer.subarray(0, bytesRead).toString("utf8")
75
+ } finally {
76
+ await fh.close()
77
+ }
78
+
79
+ let lines = text.split("\n")
80
+ if (start > desiredStart) lines = lines.slice(1)
81
+ let matchingContentMissingTimestamp = 0
82
+ let matchingContentBeforeDispatch = 0
83
+ for (const line of lines) {
84
+ if (!line.trim()) continue
85
+ let record
86
+ try {
87
+ record = JSON.parse(line)
88
+ } catch {
89
+ continue
90
+ }
91
+ const message = userMessageText(record)
92
+ if (message === null || sha256(message) !== audit.contentHash) continue
93
+ const recordAtMs = Date.parse(record?.timestamp || "")
94
+ // rollout が注入前には未生成だった場合 offset=0 になるため、同一 thread の過去の同一本文を
95
+ // durable と誤認しない。timestamp を持たない record も安全側で監査対象外にする。
96
+ if (
97
+ audit.snapshotReason &&
98
+ !Number.isFinite(recordAtMs)
99
+ ) {
100
+ matchingContentMissingTimestamp += 1
101
+ continue
102
+ }
103
+ if (audit.snapshotReason && recordAtMs < audit.dispatchedAtMs) {
104
+ matchingContentBeforeDispatch += 1
105
+ continue
106
+ }
107
+ return {
108
+ durable: true,
109
+ reason: null,
110
+ filePath,
111
+ offset: audit.offset,
112
+ matchedTimestamp: record.timestamp || null,
113
+ }
114
+ }
115
+ if (matchingContentMissingTimestamp > 0) {
116
+ return {
117
+ durable: false,
118
+ reason: "matching_content_missing_timestamp",
119
+ matchingContentMissingTimestamp,
120
+ filePath,
121
+ }
122
+ }
123
+ if (matchingContentBeforeDispatch > 0) {
124
+ return {
125
+ durable: false,
126
+ reason: "matching_content_before_dispatch",
127
+ matchingContentBeforeDispatch,
128
+ filePath,
129
+ }
130
+ }
131
+ return {
132
+ durable: false,
133
+ reason: start > desiredStart ? "content_hash_not_found_in_capped_range" : "content_hash_not_found",
134
+ filePath,
135
+ }
136
+ }
137
+
138
+ /**
139
+ * rollout flush の遅延を許容して非同期再照合する。結果は監査ログ専用で、echo による delivered
140
+ * 判定を昇格・降格しない。
141
+ */
142
+ export async function verifyCodexDeliveryAudit(
143
+ audit,
144
+ { findRolloutFile = findCodexRolloutFile, attempts = 8, intervalMs = 250 } = {},
145
+ ) {
146
+ if (!audit?.threadId || !audit?.contentHash) {
147
+ return { durable: false, reason: "invalid_audit_snapshot" }
148
+ }
149
+ let last = { durable: false, reason: "not_checked" }
150
+ let resolvedFilePath = audit.filePath || null
151
+ // snapshot 時に未発見なら毎 250ms の全セッション走査を避け、最終 attempt でだけ再解決する。
152
+ // 一度見つけた path は以後の flush 待ちで再利用する。
153
+ let resolutionAttempted = Boolean(resolvedFilePath)
154
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
155
+ try {
156
+ if (!resolvedFilePath && (!resolutionAttempted || attempt === attempts)) {
157
+ resolvedFilePath = await findRolloutFile(audit.threadId)
158
+ resolutionAttempted = true
159
+ }
160
+ if (!resolvedFilePath) {
161
+ last = { durable: false, reason: "rollout_not_found" }
162
+ } else {
163
+ last = await scanAuditRange({ ...audit, filePath: resolvedFilePath })
164
+ }
165
+ } catch (err) {
166
+ last = {
167
+ durable: false,
168
+ reason: `audit_error:${err instanceof Error ? err.message : String(err)}`,
169
+ }
170
+ }
171
+ if (last.durable) return { ...last, attempts: attempt }
172
+ if (attempt < attempts) {
173
+ await new Promise((resolve) => setTimeout(resolve, intervalMs))
174
+ }
175
+ }
176
+ return { ...last, attempts }
177
+ }