@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 +1 -1
- package/src/codex-delivery-audit.mjs +177 -0
- package/src/codex-stream-bridge.mjs +405 -55
- package/src/sibling-inject.mjs +81 -9
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
/** 連携カード種別
|
|
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
|
-
|
|
870
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
* @
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
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 {
|
|
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
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
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
|
-
|
|
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"]?.(
|
package/src/sibling-inject.mjs
CHANGED
|
@@ -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
|
|
190
|
-
|
|
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 = {
|
|
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
|
|
271
|
+
const r = bridge?.sendToCwd
|
|
272
|
+
? await bridge.sendToCwd(cwd, text, injectSource)
|
|
273
|
+
: { status: "not_found" }
|
|
239
274
|
if (r.status === "delivered") {
|
|
240
|
-
result = {
|
|
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
|
-
? {
|
|
262
|
-
|
|
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
|
|
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
|
// 要求ファイルは処理後に必ず削除する (成否問わず、再処理させないため)。
|