@cocorograph/hub-agent 0.8.1 → 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.1",
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",
@@ -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
+ }
@@ -41,8 +41,13 @@
41
41
  * 返す (browser 側での re-mount 白紙状態を解消する)。
42
42
  */
43
43
  import { EventEmitter } from "node:events"
44
+ import { randomUUID } from "node:crypto"
44
45
 
45
46
  import { CodexAppServerClient } from "./codex-appserver-client.mjs"
47
+ import {
48
+ snapshotCodexDeliveryAudit,
49
+ verifyCodexDeliveryAudit,
50
+ } from "./codex-delivery-audit.mjs"
46
51
  import { getOrCreateSiblingToken } from "./sibling-token.mjs"
47
52
  import { codexRolloutPresence, listCodexThreadsFromDisk } from "./usage.mjs"
48
53
 
@@ -128,6 +133,12 @@ const DEAD_TERMINAL_TTL_MS =
128
133
  const TURN_START_TIMEOUT_MS =
129
134
  Number(process.env.HUB_AGENT_CODEX_TURN_START_TIMEOUT_MS) || 30 * 1000
130
135
 
136
+ // sibling-inject の turn RPC が成功した後、app-server が同じ userMessage を
137
+ // item/completed で echo するまで待つ上限。RPC accepted と userMessage delivered を
138
+ // 分離し、旧 stream / 初期化競合で rollout に残らない入力を成功扱いしない。
139
+ const DELIVERY_ECHO_TIMEOUT_MS =
140
+ Number(process.env.HUB_AGENT_CODEX_DELIVERY_ECHO_TIMEOUT_MS) || 8 * 1000
141
+
131
142
  const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
132
143
 
133
144
  // T06499 (2026-07-18): 巨大スレッドの resume ガード。長期運用したスレッドの rollout は
@@ -305,6 +316,9 @@ export class CodexStreamBridge extends EventEmitter {
305
316
  stuckTurnTtlMs,
306
317
  deadTerminalTtlMs,
307
318
  turnStartTimeoutMs,
319
+ deliveryEchoTimeoutMs,
320
+ deliveryAuditSnapshot,
321
+ deliveryAuditVerify,
308
322
  rolloutPresence,
309
323
  listThreadsFromDisk,
310
324
  historyMaxItems,
@@ -325,6 +339,11 @@ export class CodexStreamBridge extends EventEmitter {
325
339
  this._deadTerminalTtlMs = deadTerminalTtlMs ?? DEAD_TERMINAL_TTL_MS
326
340
  // round3 major#2: turn/start 後に turn/started を待つ上限(既定 30s)。テストから短縮可能。
327
341
  this._turnStartTimeoutMs = turnStartTimeoutMs ?? TURN_START_TIMEOUT_MS
342
+ // sibling-inject の userMessage echo を待つ上限(既定 8s)。テストから短縮可能。
343
+ this._deliveryEchoTimeoutMs = deliveryEchoTimeoutMs ?? DELIVERY_ECHO_TIMEOUT_MS
344
+ // rollout は echo 後の二次監査専用。テストでは I/O を stub できる。
345
+ this._deliveryAuditSnapshot = deliveryAuditSnapshot || snapshotCodexDeliveryAudit
346
+ this._deliveryAuditVerify = deliveryAuditVerify || verifyCodexDeliveryAudit
328
347
  // #4 失敗分類の rollout 存在チェック (tri-state。テストから注入可能)。
329
348
  this._rolloutPresence = rolloutPresence || codexRolloutPresence
330
349
  // Phase 3 (Read-plane スライス2a): 一覧を rollout の disk 走査で作る (app-server 非 spawn)。
@@ -460,7 +479,8 @@ export class CodexStreamBridge extends EventEmitter {
460
479
  // 直列化し、二重 turn/start・queue 追い越し・並行 steer を構造的に排除する。旧 turnStarting/
461
480
  // pendingInputs の二重路 (queue 経路 + 直通経路) が overtaking と並行リトライ二重ターンの温床だった。
462
481
  /** @type {Array<{input: object[], model?: string, effort?: string, resolve: Function,
463
- * reject: Function, retriedAsStart?: boolean}>} round3 major#1: **session 単位の単一 FIFO**。
482
+ * reject: Function, retriedAsStart?: boolean, deliveryId?: string,
483
+ * sourceStreamId?: string}>} round3 major#1: **session 単位の単一 FIFO**。
464
484
  * 全 input() はここに積まれ、単一 pump (_pumpInputs) が **1 件ずつ直列**に処理する。直通経路を
465
485
  * 廃したので、drain 中の新規 input が queue を追い越したり、並行 turn/start・steer を発行して
466
486
  * 二重ターンになる事故が起きない。各 promise は **実 delivery (turn/start か turn/steer の RPC)**で
@@ -509,7 +529,9 @@ export class CodexStreamBridge extends EventEmitter {
509
529
  // 注入本文 (text) → source ('peer'|'orchestrator') を控える FIFO。sendToCwd/sendToStream が
510
530
  // 注入直前に積み、直後に app-server が返す userMessage の item/completed で本文一致により
511
531
  // _onNotification が消費して params.item.source を付ける (frontend が専用カードへ分岐)。
512
- /** @type {Array<{text: string, source: 'peer'|'orchestrator'}>} */
532
+ /** @type {Array<{text: string, source?: 'peer'|'orchestrator',
533
+ * deliveryId: string, sourceStreamId: string, deliveryPending: boolean,
534
+ * deliveryPromise: Promise<object>, settleDelivery: Function}>} */
513
535
  pendingInjectSources: [],
514
536
  }
515
537
  session.ready = new Promise((resolve) => {
@@ -524,15 +546,46 @@ export class CodexStreamBridge extends EventEmitter {
524
546
  // / MCP 起動の失敗 (thread op 未到達) は attach 失敗分類の対象外にする (pointer を消さない)。
525
547
  let reachedThreadOp = false
526
548
  try {
549
+ this.logger?.info?.(
550
+ { stream_id, cwd, resuming, lifecycle_stage: "initialize_started" },
551
+ "codex app-server session initialization",
552
+ )
527
553
  await client.request("initialize", { clientInfo: CLIENT_INFO })
554
+ // 公式 App Server handshake: initialize 応答後、同じ transport 上で initialized を
555
+ // 通知してから thread operation を開始する。現行 server の寛容動作に依存しない。
556
+ client.notify("initialized", {})
557
+ this.logger?.info?.(
558
+ { stream_id, cwd, resuming, lifecycle_stage: "initialized_sent" },
559
+ "codex app-server session initialization",
560
+ )
528
561
  reachedThreadOp = true
529
562
  const startParams = { cwd, model: model || undefined, sandbox: sandbox || undefined }
530
563
  if (effort) startParams.effort = effort
531
564
  if (approvalPolicy) startParams.approvalPolicy = approvalPolicy
565
+ this.logger?.info?.(
566
+ {
567
+ stream_id,
568
+ cwd,
569
+ resuming,
570
+ resume_thread_id: resumeThreadId || null,
571
+ lifecycle_stage: resuming ? "thread_resume_started" : "thread_start_started",
572
+ },
573
+ "codex app-server session initialization",
574
+ )
532
575
  const result = resuming
533
576
  ? await client.request("thread/resume", { ...startParams, threadId: resumeThreadId })
534
577
  : await client.request("thread/start", startParams)
535
578
  session.threadId = result?.thread?.id
579
+ this.logger?.info?.(
580
+ {
581
+ stream_id,
582
+ thread_id: session.threadId || null,
583
+ cwd,
584
+ resuming,
585
+ lifecycle_stage: resuming ? "thread_resume_completed" : "thread_start_completed",
586
+ },
587
+ "codex app-server session initialization",
588
+ )
536
589
  // 新規スレッド (thread/start) は履歴が無いので空配列。resume 時のみ平坦化する
537
590
  // (thread/start のレスポンスは turns を持たない仕様のため呼んでも常に空)。
538
591
  // T06499: 巨大スレッドは末尾 N ターンに絞る (truncated=true なら browser がバナー表示)。
@@ -808,6 +861,7 @@ export class CodexStreamBridge extends EventEmitter {
808
861
  client.start()
809
862
  try {
810
863
  await client.request("initialize", { clientInfo: CLIENT_INFO })
864
+ client.notify("initialized", {})
811
865
  const result = await client.request("account/rateLimits/read", {})
812
866
  this._accountRateLimitsByProfile.set(profileId, result)
813
867
  return result
@@ -860,22 +914,81 @@ export class CodexStreamBridge extends EventEmitter {
860
914
  return Array.from(new Set(this.sessions.values())).filter((s) => !s.dead)
861
915
  }
862
916
 
863
- /** 連携カード種別 (Phase 2): 注入本文 → source を session の FIFO に控える。sendToCwd/
917
+ /** 連携カード種別 + 配達確認: 注入本文 → entry を session の FIFO に控える。sendToCwd/
864
918
  * sendToStream が input() を呼ぶ直前に記録し、直後の userMessage echo を
865
- * _tagInjectSourceOnItem が拾う。
919
+ * _tagInjectSourceOnItem が拾う。entry 自体が delivery promise の handle であり、
920
+ * 同一本文の並行注入でも splice した **その entry だけ**を settle する。
921
+ *
922
+ * App Server の userMessage item には client correlation metadata が無いため、同一本文の
923
+ * 複数注入は session FIFO と echo FIFO が同順であることに依存する。可視 CID を本文へ混ぜて
924
+ * モデル入力を汚さず、内部 deliveryId はログ・IPC 相関に使う。
866
925
  * @param {object} session
867
926
  * @param {string} text
868
- * @param {'peer'|'orchestrator'|undefined} source */
869
- _recordInjectSource(session, text, source) {
870
- if (!session || (source !== "peer" && source !== "orchestrator") || !text) return null
927
+ * @param {'peer'|'orchestrator'|undefined} source
928
+ * @param {string} deliveryId
929
+ * @param {string} sourceStreamId */
930
+ _recordInjectSource(
931
+ session,
932
+ text,
933
+ source,
934
+ deliveryId = randomUUID(),
935
+ sourceStreamId = session?.stream_id || "",
936
+ ) {
937
+ if (!session || !text || !deliveryId) return null
871
938
  const list = session.pendingInjectSources || (session.pendingInjectSources = [])
872
- const entry = { text, source }
939
+ let settleDelivery
940
+ const deliveryPromise = new Promise((resolve) => {
941
+ settleDelivery = resolve
942
+ })
943
+ const entry = {
944
+ text,
945
+ source: source === "peer" || source === "orchestrator" ? source : undefined,
946
+ deliveryId,
947
+ sourceStreamId,
948
+ deliveryPending: true,
949
+ deliveryPromise,
950
+ settleDelivery,
951
+ }
873
952
  list.push(entry)
874
953
  // echo が来ない稀ケースの無限成長を防ぐ (連携注入は低頻度、32 件で十分)。
875
- if (list.length > 32) list.shift()
954
+ if (list.length > 32) {
955
+ const dropped = list.shift()
956
+ this._settleInjectDelivery(dropped, { delivered: false, reason: "tracking_overflow" })
957
+ }
876
958
  return entry
877
959
  }
878
960
 
961
+ /** delivery handle を一度だけ settle する。 */
962
+ _settleInjectDelivery(entry, result) {
963
+ if (!entry || !entry.deliveryPending) return
964
+ entry.deliveryPending = false
965
+ entry.settleDelivery?.(result)
966
+ }
967
+
968
+ /** session 終了時、echo 待機中の sibling injection を timeout より先に失敗確定する。 */
969
+ _settlePendingInjectDeliveries(session, reason = "session_dead") {
970
+ const pending = session?.pendingInjectSources
971
+ if (!Array.isArray(pending) || pending.length === 0) return
972
+ session.pendingInjectSources = []
973
+ for (const entry of pending) {
974
+ this._settleInjectDelivery(entry, { delivered: false, reason })
975
+ }
976
+ }
977
+
978
+ /** turn RPC accepted 後、対応する userMessage echo を timeout 付きで待つ。 */
979
+ async _waitInjectDelivery(entry) {
980
+ if (!entry) return { delivered: false, reason: "tracking_unavailable" }
981
+ if (!entry.deliveryPending) return entry.deliveryPromise
982
+ const timeout = new Promise((resolve) => {
983
+ const timer = setTimeout(() => {
984
+ this._settleInjectDelivery(entry, { delivered: false, reason: "echo_timeout" })
985
+ resolve({ delivered: false, reason: "echo_timeout" })
986
+ }, this._deliveryEchoTimeoutMs)
987
+ entry.deliveryPromise.finally(() => clearTimeout(timer))
988
+ })
989
+ return Promise.race([entry.deliveryPromise, timeout])
990
+ }
991
+
879
992
  /** 連携カード種別 (Phase 2, cross-review round1 major#1 / round2 major): 注入 (input) が配送失敗
880
993
  * したときに、record が返した **その entry だけ**を identity (indexOf) で取り消す。
881
994
  * ⚠️ text で消してはいけない (round2 指摘): 同一本文を並行注入し後発だけ失敗すると、text 先頭
@@ -886,6 +999,7 @@ export class CodexStreamBridge extends EventEmitter {
886
999
  if (!list || !handle) return
887
1000
  const idx = list.indexOf(handle)
888
1001
  if (idx !== -1) list.splice(idx, 1)
1002
+ this._settleInjectDelivery(handle, { delivered: false, reason: "input_failed" })
889
1003
  }
890
1004
 
891
1005
  /** userMessage の echo (item/completed) が控えた注入本文と一致すれば、params を **クローン**して
@@ -911,9 +1025,25 @@ export class CodexStreamBridge extends EventEmitter {
911
1025
  if (!text) return null
912
1026
  const idx = list.findIndex((p) => p.text === text)
913
1027
  if (idx === -1) return null
914
- const { source } = list[idx]
915
- list.splice(idx, 1)
916
- return { ...msg.params, item: { ...item, source } }
1028
+ const [entry] = list.splice(idx, 1)
1029
+ this._settleInjectDelivery(entry, {
1030
+ delivered: true,
1031
+ itemId: item.id || null,
1032
+ completedAtMs: msg.params?.completedAtMs || null,
1033
+ })
1034
+ this.logger?.info?.(
1035
+ {
1036
+ delivery_id: entry.deliveryId,
1037
+ stream_id: entry.sourceStreamId,
1038
+ current_stream_id: session.stream_id,
1039
+ thread_id: session.threadId,
1040
+ item_id: item.id || null,
1041
+ delivery_state: "delivered",
1042
+ },
1043
+ "codex sibling injection userMessage echo observed",
1044
+ )
1045
+ const taggedItem = entry.source ? { ...item, source: entry.source } : { ...item }
1046
+ return { ...msg.params, item: taggedItem }
917
1047
  }
918
1048
 
919
1049
  /** 案B: この session を指す全購読 stream_id キーを sessions Map から外す(reap / exit 共通)。 */
@@ -950,6 +1080,15 @@ export class CodexStreamBridge extends EventEmitter {
950
1080
  this._settleTurnActiveWaiter(session)
951
1081
  // 会話ターン境界で「新しさ」を更新(閲覧 keepalive では更新しない)。
952
1082
  session.lastTurnAtMs = Date.now()
1083
+ this.logger?.info?.(
1084
+ {
1085
+ stream_id: session.stream_id,
1086
+ thread_id: session.threadId,
1087
+ turn_id: session.activeTurnId,
1088
+ lifecycle_stage: "turn_started",
1089
+ },
1090
+ "codex app-server turn lifecycle",
1091
+ )
953
1092
  break
954
1093
  case "turn/completed":
955
1094
  session.activeTurnId = null
@@ -958,6 +1097,16 @@ export class CodexStreamBridge extends EventEmitter {
958
1097
  this._settleTurnActiveWaiter(session)
959
1098
  session.lastTurnAtMs = Date.now()
960
1099
  this._autoResolveStalePendingApprovals(session, msg.params?.turn?.id)
1100
+ this.logger?.info?.(
1101
+ {
1102
+ stream_id: session.stream_id,
1103
+ thread_id: session.threadId,
1104
+ turn_id: msg.params?.turn?.id || null,
1105
+ turn_status: msg.params?.turn?.status || null,
1106
+ lifecycle_stage: "turn_completed",
1107
+ },
1108
+ "codex app-server turn lifecycle",
1109
+ )
961
1110
  // Stage 5: turn 完了後の reap 予約は不要。以降 idle になれば周期 reconcile が
962
1111
  // 長 idle TTL / LRU で扱う(detach 済みでも 7 日 warm 保持)。
963
1112
  break
@@ -1036,6 +1185,7 @@ export class CodexStreamBridge extends EventEmitter {
1036
1185
  // round3: app-server が死んだら barrier を解除し queue を reject する(promise 宙吊り防止)。
1037
1186
  this._settleTurnActiveWaiter(session)
1038
1187
  this._drainRejectQueue(session, { reason: "queue_dead" })
1188
+ this._settlePendingInjectDeliveries(session, "session_dead")
1039
1189
  // 案B: app-server が死んだら全購読 stream_id キーを Map から外す(1 端末分だけでは
1040
1190
  // 他の購読キーが dangling で残る)。
1041
1191
  this._dropAllStreamKeys(session)
@@ -1078,7 +1228,8 @@ export class CodexStreamBridge extends EventEmitter {
1078
1228
  * ("Override the model for this turn and subsequent turns")。turn/start でのみ有効。
1079
1229
  * @param {string} [args.effort] - 公式スキーマ `TurnStartParams.effort`
1080
1230
  * ("Override the reasoning effort for this turn and subsequent turns")。turn/start でのみ有効。
1081
- * @returns {Promise<void>}
1231
+ * @param {string} [args.deliveryId] sibling-inject の内部相関 ID
1232
+ * @returns {Promise<{method: "turn/start"|"turn/steer", deliveryId?: string}>}
1082
1233
  *
1083
1234
  * ⚠️ `turn/steer` (`TurnSteerParams`) には model/effort フィールドが存在しない
1084
1235
  * (公式スキーマで未対応と確認済み、2026-07-03)。そのためターン実行中 (busy) に
@@ -1086,7 +1237,7 @@ export class CodexStreamBridge extends EventEmitter {
1086
1237
  * (= turn/start) から有効になる。呼び出し側 (main.mjs) はこれを承知の上で常に
1087
1238
  * model/effort を渡してよい (steer 分岐では単に無視される)。
1088
1239
  */
1089
- async input({ stream_id, message, model, effort }) {
1240
+ async input({ stream_id, message, model, effort, deliveryId }) {
1090
1241
  const session = this._require(stream_id)
1091
1242
  // Stage 5: _touch で lastActivityMs を更新 = LRU 先頭へ。ターン開始で activeTurnId が
1092
1243
  // 立ち、reconcile は healthy busy を protected 扱いにするので実行中に kill されない。
@@ -1096,7 +1247,16 @@ export class CodexStreamBridge extends EventEmitter {
1096
1247
  // round3 major#1: 全 input を **session 単位の単一 FIFO** に積み、単一 pump に処理させる。
1097
1248
  // promise は pump が実 delivery で settle する (即 resolve しない)。
1098
1249
  return new Promise((resolve, reject) => {
1099
- session.inputQueue.push({ input, model, effort, resolve, reject, retriedAsStart: false })
1250
+ session.inputQueue.push({
1251
+ input,
1252
+ model,
1253
+ effort,
1254
+ deliveryId,
1255
+ sourceStreamId: stream_id,
1256
+ resolve,
1257
+ reject,
1258
+ retriedAsStart: false,
1259
+ })
1100
1260
  void this._pumpInputs(session)
1101
1261
  })
1102
1262
  }
@@ -1133,7 +1293,19 @@ export class CodexStreamBridge extends EventEmitter {
1133
1293
  input: item.input,
1134
1294
  })
1135
1295
  session.inputQueue.shift()
1136
- item.resolve()
1296
+ this.logger?.info?.(
1297
+ {
1298
+ delivery_id: item.deliveryId || null,
1299
+ stream_id: item.sourceStreamId || session.stream_id,
1300
+ current_stream_id: session.stream_id,
1301
+ thread_id: session.threadId,
1302
+ attempted_steer: true,
1303
+ rpc_method: "turn/steer",
1304
+ rpc_result: "accepted",
1305
+ },
1306
+ "codex input RPC settled",
1307
+ )
1308
+ item.resolve({ method: "turn/steer", deliveryId: item.deliveryId })
1137
1309
  } else {
1138
1310
  // round4 major#2: turn/start dispatch の **前に** 世代を控える。start 応答後に
1139
1311
  // turn/started(+completed) が pump 再開前に処理されても、世代前進で「ターンが始まった」を
@@ -1144,7 +1316,19 @@ export class CodexStreamBridge extends EventEmitter {
1144
1316
  if (item.effort) params.effort = item.effort
1145
1317
  await session.client.request("turn/start", params)
1146
1318
  session.inputQueue.shift()
1147
- item.resolve()
1319
+ this.logger?.info?.(
1320
+ {
1321
+ delivery_id: item.deliveryId || null,
1322
+ stream_id: item.sourceStreamId || session.stream_id,
1323
+ current_stream_id: session.stream_id,
1324
+ thread_id: session.threadId,
1325
+ attempted_steer: false,
1326
+ rpc_method: "turn/start",
1327
+ rpc_result: "accepted",
1328
+ },
1329
+ "codex input RPC settled",
1330
+ )
1331
+ item.resolve({ method: "turn/start", deliveryId: item.deliveryId })
1148
1332
  // 次の item を steer にするため turn/started (= 世代前進) を待つ。timeout でハングを防ぐ。
1149
1333
  // timeout したら「ターンが正しく始まらなかった」とみなし残り queue を reject して打ち切る。
1150
1334
  const becameActive = await this._waitTurnActive(session, genBefore)
@@ -1155,6 +1339,19 @@ export class CodexStreamBridge extends EventEmitter {
1155
1339
  }
1156
1340
  } catch (err) {
1157
1341
  if (attemptedSteer && !item.retriedAsStart) {
1342
+ this.logger?.warn?.(
1343
+ {
1344
+ delivery_id: item.deliveryId || null,
1345
+ stream_id: item.sourceStreamId || session.stream_id,
1346
+ current_stream_id: session.stream_id,
1347
+ thread_id: session.threadId,
1348
+ attempted_steer: true,
1349
+ rpc_method: "turn/steer",
1350
+ rpc_result: "retry_as_start",
1351
+ err: err instanceof Error ? err.message : String(err),
1352
+ },
1353
+ "codex input RPC settled",
1354
+ )
1158
1355
  // turn/steer 失敗 → 自己修復: activeTurnId を捨て、同 item を turn/start で 1 回だけリトライ
1159
1356
  // (shift しない = 同 item を次ループの start 経路で再処理)。並行はしない (単一 pump)。
1160
1357
  // 元の steer エラーを stash して、リトライも失敗したとき合成メッセージを作れるようにする。
@@ -1181,6 +1378,20 @@ export class CodexStreamBridge extends EventEmitter {
1181
1378
  e = err instanceof Error ? err : new Error(String(err))
1182
1379
  if (!e.reason) e.reason = "start_error"
1183
1380
  }
1381
+ this.logger?.error?.(
1382
+ {
1383
+ delivery_id: item.deliveryId || null,
1384
+ stream_id: item.sourceStreamId || session.stream_id,
1385
+ current_stream_id: session.stream_id,
1386
+ thread_id: session.threadId,
1387
+ attempted_steer: attemptedSteer,
1388
+ rpc_method: attemptedSteer ? "turn/steer" : "turn/start",
1389
+ rpc_result: "failed",
1390
+ err: e.message,
1391
+ reason: e.reason || null,
1392
+ },
1393
+ "codex input RPC settled",
1394
+ )
1184
1395
  item.reject(e)
1185
1396
  }
1186
1397
  }
@@ -1490,9 +1701,14 @@ export class CodexStreamBridge extends EventEmitter {
1490
1701
  *
1491
1702
  * @param {string} cwd
1492
1703
  * @param {string} text
1493
- * @returns {{status:"delivered", session:object} | {status:"not_found"}}
1704
+ * @returns {Promise<
1705
+ * {status:"delivered", session:object, delivery_id:string, accepted_via:string}
1706
+ * | {status:"delivery_unconfirmed", session:object, delivery_id:string, accepted_via:string, reason:string}
1707
+ * | {status:"delivery_failed", session:object, delivery_id:string, reason:string}
1708
+ * | {status:"not_found"}
1709
+ * >}
1494
1710
  */
1495
- sendToCwd(cwd, text, source) {
1711
+ async sendToCwd(cwd, text, source) {
1496
1712
  if (!cwd || !text) return { status: "not_found" }
1497
1713
  // 案B: distinct session で走査(多対一 Map の重複を除く)。
1498
1714
  const cands = this._liveSessions().filter((s) => s.cwd === cwd)
@@ -1525,29 +1741,11 @@ export class CodexStreamBridge extends EventEmitter {
1525
1741
  // 明示しておく。ターン実行中は healthy busy = protected なので reconcile で kill されない。
1526
1742
  best.detachRequested = false
1527
1743
  this._touch(best)
1528
- // 連携カード種別 (Phase 2): 宛先確定後に記録 (not_found では記録しない)。handle を控え失敗時に除去。
1529
- const injectHandle = this._recordInjectSource(best, text, source)
1530
- this.logger?.info?.(
1531
- {
1532
- cwd,
1533
- stream_id: best.stream_id,
1534
- thread_id: best.threadId,
1535
- busy: !!best.activeTurnId,
1536
- candidates: this.describeLiveSessions(cwd).length,
1537
- },
1538
- "codex sendToCwd: injecting into most-recently-active session",
1539
- )
1540
- this.input({ stream_id: best.stream_id, message: { role: "user", content: text } }).catch(
1541
- (err) => {
1542
- // Phase 2: 配送失敗なら echo は来ないので stale な source entry を取り消す (誤タグ防止)。
1543
- this._removeInjectSource(best, injectHandle)
1544
- this.logger?.error(
1545
- { cwd, stream_id: best.stream_id, err: err?.message },
1546
- "sendToCwd: input threw unexpectedly",
1547
- )
1548
- },
1549
- )
1550
- return { status: "delivered", session: this._describe(best) }
1744
+ return this._deliverInjection(best, text, source, {
1745
+ cwd,
1746
+ selection: "most-recently-active",
1747
+ candidates: this.describeLiveSessions(cwd).length,
1748
+ })
1551
1749
  }
1552
1750
 
1553
1751
  /**
@@ -1576,9 +1774,9 @@ export class CodexStreamBridge extends EventEmitter {
1576
1774
  * @param {string} cwd 呼び出し元がトークン検証した cwd (この cwd のセッションにのみ注入)
1577
1775
  * @param {string} id 宛先 stream_id (一意・推奨) または thread_id
1578
1776
  * @param {string} text
1579
- * @returns {{status: "delivered", session: object} | {status: "not_found"} | {status: "ambiguous", candidates: object[]}}
1777
+ * @returns {Promise<object>}
1580
1778
  */
1581
- sendToStream(cwd, id, text, source) {
1779
+ async sendToStream(cwd, id, text, source) {
1582
1780
  if (!cwd || !id || !text) return { status: "not_found" }
1583
1781
  let bySid = null
1584
1782
  const byThread = []
@@ -1604,19 +1802,166 @@ export class CodexStreamBridge extends EventEmitter {
1604
1802
  // (sendToCwd と同じ扱い)。
1605
1803
  target.detachRequested = false
1606
1804
  this._touch(target)
1607
- // 連携カード種別 (Phase 2): 宛先確定後に記録 (not_found/ambiguous では記録しない)。handle を控える。
1608
- const injectHandle = this._recordInjectSource(target, text, source)
1609
- this.input({ stream_id: target.stream_id, message: { role: "user", content: text } }).catch(
1610
- (err) => {
1611
- // Phase 2: 配送失敗なら echo は来ないので stale な source entry を取り消す (誤タグ防止)。
1612
- this._removeInjectSource(target, injectHandle)
1613
- this.logger?.error(
1614
- { id, stream_id: target.stream_id, err: err?.message },
1615
- "sendToStream: input threw unexpectedly",
1616
- )
1805
+ return this._deliverInjection(target, text, source, {
1806
+ cwd,
1807
+ requested_id: id,
1808
+ selection: bySid ? "stream_id" : "thread_id",
1809
+ })
1810
+ }
1811
+
1812
+ /**
1813
+ * sibling-inject の4段階:
1814
+ * dispatched (本関数開始) → accepted (turn RPC成功) →
1815
+ * delivered (同一sessionの userMessage item/completed echo) → durable (rollout監査、別層)。
1816
+ * ここでは delivered までを同期的に確認し、echo timeout は成功扱いしない。
1817
+ */
1818
+ async _deliverInjection(session, text, source, context = {}) {
1819
+ const deliveryId = randomUUID()
1820
+ const sourceStreamId = session.stream_id
1821
+ const injectHandle = this._recordInjectSource(
1822
+ session,
1823
+ text,
1824
+ source,
1825
+ deliveryId,
1826
+ sourceStreamId,
1827
+ )
1828
+ // 監査 offset の取得中も旧 stream を per-cwd reconcile から守るため、delivery handle を先に積む。
1829
+ // rollout 監査は二次確認なので、snapshot 自体の失敗で配送を止めない。
1830
+ let durableAudit
1831
+ try {
1832
+ durableAudit = await this._deliveryAuditSnapshot(session.threadId, text)
1833
+ } catch (err) {
1834
+ durableAudit = {
1835
+ threadId: session.threadId,
1836
+ contentHash: null,
1837
+ filePath: null,
1838
+ offset: 0,
1839
+ snapshotReason: `snapshot_error:${err instanceof Error ? err.message : String(err)}`,
1840
+ }
1841
+ }
1842
+ this.logger?.info?.(
1843
+ {
1844
+ ...context,
1845
+ delivery_id: deliveryId,
1846
+ stream_id: sourceStreamId,
1847
+ thread_id: session.threadId,
1848
+ busy: !!session.activeTurnId,
1849
+ delivery_state: "dispatched",
1850
+ durable_content_hash: durableAudit?.contentHash || null,
1851
+ durable_offset: durableAudit?.offset ?? null,
1852
+ durable_snapshot_reason: durableAudit?.snapshotReason || null,
1617
1853
  },
1854
+ "codex sibling injection dispatched",
1618
1855
  )
1619
- return { status: "delivered", session: this._describe(target) }
1856
+
1857
+ let accepted
1858
+ try {
1859
+ accepted = await this.input({
1860
+ stream_id: sourceStreamId,
1861
+ message: { role: "user", content: text },
1862
+ deliveryId,
1863
+ })
1864
+ } catch (err) {
1865
+ this._removeInjectSource(session, injectHandle)
1866
+ this.logger?.error?.(
1867
+ {
1868
+ ...context,
1869
+ delivery_id: deliveryId,
1870
+ stream_id: sourceStreamId,
1871
+ current_stream_id: session.stream_id,
1872
+ thread_id: session.threadId,
1873
+ delivery_state: "failed",
1874
+ err: err instanceof Error ? err.message : String(err),
1875
+ },
1876
+ "codex sibling injection failed before acceptance",
1877
+ )
1878
+ return {
1879
+ status: "delivery_failed",
1880
+ session: this._describe(session),
1881
+ delivery_id: deliveryId,
1882
+ reason: err?.reason || err?.message || "input_failed",
1883
+ }
1884
+ }
1885
+
1886
+ this.logger?.info?.(
1887
+ {
1888
+ ...context,
1889
+ delivery_id: deliveryId,
1890
+ stream_id: sourceStreamId,
1891
+ current_stream_id: session.stream_id,
1892
+ thread_id: session.threadId,
1893
+ accepted_via: accepted?.method || null,
1894
+ delivery_state: "accepted",
1895
+ },
1896
+ "codex sibling injection accepted",
1897
+ )
1898
+ const delivery = await this._waitInjectDelivery(injectHandle)
1899
+ if (!delivery?.delivered) {
1900
+ this.logger?.warn?.(
1901
+ {
1902
+ ...context,
1903
+ delivery_id: deliveryId,
1904
+ stream_id: sourceStreamId,
1905
+ current_stream_id: session.stream_id,
1906
+ thread_id: session.threadId,
1907
+ accepted_via: accepted?.method || null,
1908
+ delivery_state: "unconfirmed",
1909
+ reason: delivery?.reason || "echo_timeout",
1910
+ live: !session.dead,
1911
+ busy: !!session.activeTurnId,
1912
+ },
1913
+ "codex sibling injection delivery unconfirmed",
1914
+ )
1915
+ return {
1916
+ status: "delivery_unconfirmed",
1917
+ session: this._describe(session),
1918
+ delivery_id: deliveryId,
1919
+ accepted_via: accepted?.method || null,
1920
+ reason: delivery?.reason || "echo_timeout",
1921
+ }
1922
+ }
1923
+ // rollout は内部形式・flush 遅延の影響を受けるため primary oracle にしない。echo delivered を
1924
+ // 返した後、注入前 offset + threadId + 本文 hash で非同期監査し、結果だけを構造化ログへ残す。
1925
+ void this._deliveryAuditVerify(durableAudit)
1926
+ .then((auditResult) => {
1927
+ this.logger?.[auditResult?.durable ? "info" : "warn"]?.(
1928
+ {
1929
+ ...context,
1930
+ delivery_id: deliveryId,
1931
+ stream_id: sourceStreamId,
1932
+ current_stream_id: session.stream_id,
1933
+ thread_id: session.threadId,
1934
+ delivery_state: auditResult?.durable ? "durable" : "durable_unconfirmed",
1935
+ durable_content_hash: durableAudit?.contentHash || null,
1936
+ durable_offset: durableAudit?.offset ?? null,
1937
+ durable_file: auditResult?.filePath || durableAudit?.filePath || null,
1938
+ durable_reason: auditResult?.reason || null,
1939
+ durable_attempts: auditResult?.attempts || null,
1940
+ },
1941
+ "codex sibling injection rollout durability audit",
1942
+ )
1943
+ })
1944
+ .catch((err) => {
1945
+ this.logger?.warn?.(
1946
+ {
1947
+ ...context,
1948
+ delivery_id: deliveryId,
1949
+ stream_id: sourceStreamId,
1950
+ thread_id: session.threadId,
1951
+ delivery_state: "durable_unconfirmed",
1952
+ durable_reason: `audit_error:${err instanceof Error ? err.message : String(err)}`,
1953
+ },
1954
+ "codex sibling injection rollout durability audit",
1955
+ )
1956
+ })
1957
+ return {
1958
+ status: "delivered",
1959
+ session: this._describe(session),
1960
+ delivery_id: deliveryId,
1961
+ accepted_via: accepted?.method || null,
1962
+ item_id: delivery.itemId || null,
1963
+ durable_state: "pending",
1964
+ }
1620
1965
  }
1621
1966
 
1622
1967
  /** describeLiveSessions と同形の 1 セッション記述子 (sendToStream の結果整形に共用)。 */
@@ -1651,6 +1996,9 @@ export class CodexStreamBridge extends EventEmitter {
1651
1996
  if (session.pumping) return true
1652
1997
  if (Array.isArray(session.inputQueue) && session.inputQueue.length > 0) return true
1653
1998
  if (session._turnActiveWaiter) return true
1999
+ // turn RPC accepted 後、対応 userMessage echo を待っている間も protected。
2000
+ // ここを外すと別 attach の per-cwd reconcile が ACK 先を配達確認前に reap できる。
2001
+ if (session.pendingInjectSources?.some((entry) => entry.deliveryPending)) return true
1654
2002
  if (!session.activeTurnId) return false
1655
2003
  return now - (session.lastActivityMs ?? now) <= this._stuckTurnTtlMs
1656
2004
  }
@@ -1662,6 +2010,7 @@ export class CodexStreamBridge extends EventEmitter {
1662
2010
  // round3: reap した session の barrier を解除し queue を reject する(promise 宙吊り防止)。
1663
2011
  this._settleTurnActiveWaiter(session)
1664
2012
  this._drainRejectQueue(session, { reason: "queue_dead" })
2013
+ this._settlePendingInjectDeliveries(session, "session_dead")
1665
2014
  // 案B: 全購読 stream_id キーを Map から外す(primary 1 個だけ消すと dangling が残る)。
1666
2015
  this._dropAllStreamKeys(session)
1667
2016
  try {
@@ -1822,6 +2171,7 @@ export class CodexStreamBridge extends EventEmitter {
1822
2171
  // round3: shutdown で barrier を解除し queue 入力を reject(promise 宙吊り防止)。
1823
2172
  this._settleTurnActiveWaiter(session)
1824
2173
  this._drainRejectQueue(session, { reason: "queue_dead" })
2174
+ this._settlePendingInjectDeliveries(session, "session_dead")
1825
2175
  // hub-agent 再起動時にターン実行中のセッションを落とすと thread が汚染されうる
1826
2176
  // (exec 中の kill → output 欠落)。因果を後から辿れるよう必ず記録する。
1827
2177
  this.logger?.[session.activeTurnId ? "warn" : "info"]?.(
@@ -45,6 +45,16 @@ import { verifySiblingTokenAny, siblingTokenCwd } from "./sibling-token.mjs"
45
45
  export const SIBLING_REQUESTS_DIR =
46
46
  process.env.COCKPIT_SIBLING_REQUESTS_DIR || "/tmp/cockpit_sibling_requests"
47
47
 
48
+ const DELIVERY_UNCONFIRMED_HINT =
49
+ "turn RPC は受理されましたが userMessage echo を確認できませんでした。既に届いている可能性があり、" +
50
+ "再送すると重複配送になります。再送前に対象セッションの受信状態または rollout 監査を確認してください。"
51
+ const DELIVERY_FAILED_HINT =
52
+ "turn RPC の受付前または受付中に失敗しました。対象セッションを確認して再送してください。"
53
+
54
+ function deliveredStateFor(kind) {
55
+ return kind === "codex" ? "delivered" : "dispatched"
56
+ }
57
+
48
58
  export class SiblingInjectBridge {
49
59
  /**
50
60
  * @param {{dir?: string, claudeBridge?: object, codexBridge?: object, logger?: object}} [opts]
@@ -186,11 +196,34 @@ export class SiblingInjectBridge {
186
196
  } else if (isExplicit) {
187
197
  // 明示宛先: session_id (claude) / stream_id|thread_id (codex) で cwd 一致下に確定解決。
188
198
  // sendToStream は {status:"delivered"|"not_found"|"ambiguous"} を返す。
189
- const r = bridge?.sendToStream?.(cwd, target_session_id, message, injectSource) ?? {
190
- status: "not_found",
191
- }
199
+ const r = bridge?.sendToStream
200
+ ? await bridge.sendToStream(cwd, target_session_id, message, injectSource)
201
+ : { status: "not_found" }
192
202
  if (r.status === "delivered") {
193
- result = { ok: true, delivered_to: r.session }
203
+ result = {
204
+ ok: true,
205
+ delivered_to: r.session,
206
+ delivery_id: r.delivery_id,
207
+ delivery_state: deliveredStateFor(target_cli_kind),
208
+ durable_state: r.durable_state,
209
+ accepted_via: r.accepted_via,
210
+ item_id: r.item_id,
211
+ }
212
+ } else if (r.status === "delivery_unconfirmed" || r.status === "delivery_failed") {
213
+ result = {
214
+ ok: false,
215
+ error: r.status,
216
+ delivered_to: r.session,
217
+ delivery_id: r.delivery_id,
218
+ delivery_state:
219
+ r.status === "delivery_unconfirmed" ? "accepted_unconfirmed" : "failed",
220
+ accepted_via: r.accepted_via,
221
+ reason: r.reason,
222
+ hint:
223
+ r.status === "delivery_unconfirmed"
224
+ ? DELIVERY_UNCONFIRMED_HINT
225
+ : DELIVERY_FAILED_HINT,
226
+ }
194
227
  } else if (r.status === "ambiguous") {
195
228
  // 宛先 id が同一 cwd の複数 live セッションに一致し一意に定まらない。
196
229
  // ⚠️ 解消手段は CLI で異なる (T06271 レビュー4 minor):
@@ -235,9 +268,34 @@ export class SiblingInjectBridge {
235
268
  // (サイレント誤配送 + delivered_to に実配送先でない sameCwd[0] を報告する成功偽装) が
236
269
  // あった。sendToCwd を status 返却にして解決と配送を 1 呼び出しに統合し、delivered_to は
237
270
  // **実配送先 (r.session)** を返す。
238
- const r = bridge?.sendToCwd?.(cwd, text, injectSource) ?? { status: "not_found" }
271
+ const r = bridge?.sendToCwd
272
+ ? await bridge.sendToCwd(cwd, text, injectSource)
273
+ : { status: "not_found" }
239
274
  if (r.status === "delivered") {
240
- result = { ok: true, delivered_to: r.session ?? null }
275
+ result = {
276
+ ok: true,
277
+ delivered_to: r.session ?? null,
278
+ delivery_id: r.delivery_id,
279
+ delivery_state: deliveredStateFor(target_cli_kind),
280
+ durable_state: r.durable_state,
281
+ accepted_via: r.accepted_via,
282
+ item_id: r.item_id,
283
+ }
284
+ } else if (r.status === "delivery_unconfirmed" || r.status === "delivery_failed") {
285
+ result = {
286
+ ok: false,
287
+ error: r.status,
288
+ delivered_to: r.session ?? null,
289
+ delivery_id: r.delivery_id,
290
+ delivery_state:
291
+ r.status === "delivery_unconfirmed" ? "accepted_unconfirmed" : "failed",
292
+ accepted_via: r.accepted_via,
293
+ reason: r.reason,
294
+ hint:
295
+ r.status === "delivery_unconfirmed"
296
+ ? DELIVERY_UNCONFIRMED_HINT
297
+ : DELIVERY_FAILED_HINT,
298
+ }
241
299
  } else if (r.status === "ambiguous") {
242
300
  result = {
243
301
  ok: false,
@@ -258,10 +316,24 @@ export class SiblingInjectBridge {
258
316
  target_cli_kind,
259
317
  op: op || "normal",
260
318
  ...(result.ok
261
- ? { delivered_to: result.delivered_to }
262
- : { error: result.error, live_sessions: result.live_sessions }),
319
+ ? {
320
+ delivered_to: result.delivered_to,
321
+ delivery_id: result.delivery_id,
322
+ delivery_state: result.delivery_state,
323
+ durable_state: result.durable_state,
324
+ }
325
+ : {
326
+ error: result.error,
327
+ delivery_id: result.delivery_id,
328
+ delivery_state: result.delivery_state,
329
+ live_sessions: result.live_sessions,
330
+ }),
263
331
  },
264
- result.ok ? "sibling-inject delivered" : "sibling-inject failed",
332
+ result.ok
333
+ ? result.delivery_state === "delivered"
334
+ ? "sibling-inject delivered"
335
+ : "sibling-inject dispatched"
336
+ : "sibling-inject failed",
265
337
  )
266
338
  await this._writeResult(request_id, result)
267
339
  // 要求ファイルは処理後に必ず削除する (成否問わず、再処理させないため)。