@cocorograph/hub-agent 0.7.85 → 0.7.86
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-stream-bridge.mjs +57 -7
- package/src/main.mjs +176 -24
- package/src/usage.mjs +119 -13
package/package.json
CHANGED
|
@@ -870,6 +870,11 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
870
870
|
}
|
|
871
871
|
this.emit("event", {
|
|
872
872
|
stream_id,
|
|
873
|
+
// producer_seq: この通知を出した **session(producer) の immutable ID**(生成時に振る
|
|
874
|
+
// 単調増加 seq)。stream_id は購読端末で reattach/detach で付け替わるため、context% の
|
|
875
|
+
// live store キーには使えない (T06407 増分2: stale slot / Map リークの原因)。producer 単位で
|
|
876
|
+
// live tokenUsage を保持し、cwd の権威 producer で代表を選ぶために main.mjs へ渡す。
|
|
877
|
+
producer_seq: session.seq,
|
|
873
878
|
thread_id: session.threadId,
|
|
874
879
|
cwd: session.cwd,
|
|
875
880
|
notification: { method: msg.method, params: msg.params },
|
|
@@ -957,6 +962,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
957
962
|
}
|
|
958
963
|
this.emit("exit", {
|
|
959
964
|
stream_id: session.stream_id,
|
|
965
|
+
producer_seq: session.seq,
|
|
960
966
|
thread_id: session.threadId,
|
|
961
967
|
code: info.code,
|
|
962
968
|
reason: info.signal ? `signal:${info.signal}` : "process-exit",
|
|
@@ -1316,21 +1322,51 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1316
1322
|
return out
|
|
1317
1323
|
}
|
|
1318
1324
|
|
|
1325
|
+
/**
|
|
1326
|
+
* 指定 cwd の「権威セッション」を返す(T06407 増分2, axis2 の単一正本)。同一 cwd に複数
|
|
1327
|
+
* ライブ codex セッションがあるとき、**最終活性が最新のもの**(=いまユーザーが使っている
|
|
1328
|
+
* 会話)を選ぶ。sendToCwd の宛先選定と同じ規則に揃える。
|
|
1329
|
+
*
|
|
1330
|
+
* ⚠️ context%(ドーナツ)は thread 固有の値なので「どのセッションから読んでも同じ」という
|
|
1331
|
+
* 旧 rate-limit 前提(先着 1 件で十分)は成り立たない。state loop(threadIdForCwd 経由)と
|
|
1332
|
+
* usage.response(per-cwd 集計)が **別の thread を選ぶと両経路が収束せずドーナツが前後する**
|
|
1333
|
+
* ため、両経路をこの一意規則へ寄せる。「最新」は閲覧 keepalive で動く lastActivityMs では
|
|
1334
|
+
* なく会話ターン境界時刻 lastTurnAtMs を基準にする(見ているだけの古い会話が本命を追い越す
|
|
1335
|
+
* のを防ぐ)。
|
|
1336
|
+
*
|
|
1337
|
+
* @param {string} cwd
|
|
1338
|
+
* @returns {object|null} 権威セッション(無ければ null)
|
|
1339
|
+
*/
|
|
1340
|
+
liveSessionForCwd(cwd) {
|
|
1341
|
+
if (!cwd) return null
|
|
1342
|
+
let best = null
|
|
1343
|
+
let bestAt = -1
|
|
1344
|
+
// 案B: distinct session で走査(多対一 Map の重複を除く)。
|
|
1345
|
+
for (const session of this._liveSessions()) {
|
|
1346
|
+
if (session.cwd !== cwd || !session.threadId) continue
|
|
1347
|
+
const at = session.lastTurnAtMs ?? session.lastActivityMs ?? 0
|
|
1348
|
+
if (at >= bestAt) {
|
|
1349
|
+
bestAt = at
|
|
1350
|
+
best = session
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
return best
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1319
1356
|
/**
|
|
1320
1357
|
* 指定 cwd に一致するライブ (attach 済み) codex セッションの threadId を返す。
|
|
1321
1358
|
* Claude 版 `liveSessionIdForCwd` の対 (main.mjs の state loop / terminal mirror から
|
|
1322
|
-
* cwd → threadId 解決に使う)
|
|
1323
|
-
*
|
|
1359
|
+
* cwd → threadId 解決に使う)。
|
|
1360
|
+
*
|
|
1361
|
+
* ⚠️ T06407 増分2: 旧実装は「先に見つかったもの」を返していたが、これは 5h/7d(アカウント
|
|
1362
|
+
* 全体で代表 1 件で十分)前提だった。context% は thread 固有なので liveSessionForCwd の
|
|
1363
|
+
* 権威規則(最終活性が最新)で解決し、usage.response の per-cwd 集計と収束させる。
|
|
1324
1364
|
*
|
|
1325
1365
|
* @param {string} cwd
|
|
1326
1366
|
* @returns {string|null}
|
|
1327
1367
|
*/
|
|
1328
1368
|
threadIdForCwd(cwd) {
|
|
1329
|
-
|
|
1330
|
-
for (const session of this.sessions.values()) {
|
|
1331
|
-
if (session.cwd === cwd && session.threadId) return session.threadId
|
|
1332
|
-
}
|
|
1333
|
-
return null
|
|
1369
|
+
return this.liveSessionForCwd(cwd)?.threadId ?? null
|
|
1334
1370
|
}
|
|
1335
1371
|
|
|
1336
1372
|
/**
|
|
@@ -1526,6 +1562,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1526
1562
|
)
|
|
1527
1563
|
this.emit("exit", {
|
|
1528
1564
|
stream_id: session.stream_id,
|
|
1565
|
+
producer_seq: session.seq,
|
|
1529
1566
|
thread_id: session.threadId,
|
|
1530
1567
|
code: 0,
|
|
1531
1568
|
reason: "idle-reaped",
|
|
@@ -1679,6 +1716,19 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1679
1716
|
? "codex session killed MID-TURN by agent shutdown"
|
|
1680
1717
|
: "codex session stopped by agent shutdown",
|
|
1681
1718
|
)
|
|
1719
|
+
// T06407 増分2 (codex-review round2 major#1): shutdown 経由の撤去 (codex.profile.switch /
|
|
1720
|
+
// プロセス終了) でも producer live store を掃除する。この後の client.stop() が誘発する
|
|
1721
|
+
// client "exit" は _onClientExit が session.dead=true で early-return するため exit を
|
|
1722
|
+
// emit しない。ここで明示的に emit し、main.mjs の exit ハンドラ
|
|
1723
|
+
// (forgetCodexLiveTokenUsage) を必ず通す。これが無いと profile 切替ごとに producer Map が
|
|
1724
|
+
// リークする。dead は既に上で立てているので二重 emit にはならない。
|
|
1725
|
+
this.emit("exit", {
|
|
1726
|
+
stream_id: session.stream_id,
|
|
1727
|
+
producer_seq: session.seq,
|
|
1728
|
+
thread_id: session.threadId,
|
|
1729
|
+
code: 0,
|
|
1730
|
+
reason: "agent-shutdown",
|
|
1731
|
+
})
|
|
1682
1732
|
session.client.stop()
|
|
1683
1733
|
}
|
|
1684
1734
|
this.sessions.clear()
|
package/src/main.mjs
CHANGED
|
@@ -117,16 +117,20 @@ import { JsonlLiveWatchers } from "./jsonl-live-watchers.mjs"
|
|
|
117
117
|
import { SiblingInjectBridge } from "./sibling-inject.mjs"
|
|
118
118
|
import {
|
|
119
119
|
boundSessionId,
|
|
120
|
+
codexContextPercentFromTokenUsage,
|
|
121
|
+
codexLiveContextForProducer,
|
|
120
122
|
codexSessionUsages,
|
|
121
123
|
codexTurnActiveForThread,
|
|
122
124
|
codexUsageForThread,
|
|
123
125
|
contextWindowSize,
|
|
126
|
+
forgetCodexLiveTokenUsage,
|
|
124
127
|
getSessionUsages,
|
|
125
128
|
getUsage,
|
|
126
129
|
isFreshUnboundBind,
|
|
127
130
|
jsonlContextForCwd,
|
|
128
131
|
recordChatRateLimit,
|
|
129
132
|
recordChatUsageSnapshot,
|
|
133
|
+
recordCodexLiveTokenUsage,
|
|
130
134
|
turnActiveForCwd,
|
|
131
135
|
} from "./usage.mjs"
|
|
132
136
|
import {
|
|
@@ -966,7 +970,15 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
966
970
|
// (codex 側にまだ存在しない/後続スライス)。ステータスドット用のチャット信号は
|
|
967
971
|
// turn/started・turn/completed のみ最小限で反映する (claude の assistant/result 相当)。
|
|
968
972
|
if (codexBridge) {
|
|
969
|
-
|
|
973
|
+
// T06407 増分2 (codex-review round1 major#3): この producer(session.seq) が cwd の
|
|
974
|
+
// **権威 producer** (liveSessionForCwd = 会話ターン最新) かを判定する。チャット信号
|
|
975
|
+
// (recordChatActivity) の context/sessionId は権威 producer の分だけ書く。非権威 producer
|
|
976
|
+
// の context/sessionId をチャット信号へ書くと、権威 thread の live 未取得の窓で overlay 経由
|
|
977
|
+
// に混入し、usage.response (権威 producer ベース) と分岐してドーナツが前後する。
|
|
978
|
+
const isCodexAuthority = (cwd, producerSeq) =>
|
|
979
|
+
typeof producerSeq === "number" &&
|
|
980
|
+
codexBridge?.liveSessionForCwd?.(cwd)?.seq === producerSeq
|
|
981
|
+
codexBridge.on("event", ({ stream_id, producer_seq, thread_id, cwd, notification }) => {
|
|
970
982
|
if (notification.method === "turn/started") {
|
|
971
983
|
// S1 (2026-07): contextPct を渡していなかったため、Codex 選択中は状態ドット
|
|
972
984
|
// (processing/waiting) こそ更新されるがコンテキスト% は claude 由来の古い値の
|
|
@@ -975,18 +987,17 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
975
987
|
;(async () => {
|
|
976
988
|
let contextPct = null
|
|
977
989
|
try {
|
|
978
|
-
const usage = await codexUsageForThread(thread_id)
|
|
990
|
+
const usage = await codexUsageForThread(thread_id, { producerSeq: producer_seq })
|
|
979
991
|
contextPct = typeof usage?.context === "number" ? usage.context : null
|
|
980
992
|
} catch {
|
|
981
993
|
/* ignore */
|
|
982
994
|
}
|
|
983
995
|
try {
|
|
984
|
-
recordChatActivity(
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
})
|
|
996
|
+
recordChatActivity(
|
|
997
|
+
cwd,
|
|
998
|
+
"codex",
|
|
999
|
+
buildCodexChatPatch("processing", contextPct, thread_id, isCodexAuthority(cwd, producer_seq)),
|
|
1000
|
+
)
|
|
990
1001
|
} catch {
|
|
991
1002
|
/* ignore */
|
|
992
1003
|
}
|
|
@@ -995,22 +1006,46 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
995
1006
|
;(async () => {
|
|
996
1007
|
let contextPct = null
|
|
997
1008
|
try {
|
|
998
|
-
const usage = await codexUsageForThread(thread_id)
|
|
1009
|
+
const usage = await codexUsageForThread(thread_id, { producerSeq: producer_seq })
|
|
999
1010
|
contextPct = typeof usage?.context === "number" ? usage.context : null
|
|
1000
1011
|
} catch {
|
|
1001
1012
|
/* ignore */
|
|
1002
1013
|
}
|
|
1003
1014
|
try {
|
|
1004
|
-
recordChatActivity(
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
})
|
|
1015
|
+
recordChatActivity(
|
|
1016
|
+
cwd,
|
|
1017
|
+
"codex",
|
|
1018
|
+
buildCodexChatPatch("waiting", contextPct, thread_id, isCodexAuthority(cwd, producer_seq)),
|
|
1019
|
+
)
|
|
1010
1020
|
} catch {
|
|
1011
1021
|
/* ignore */
|
|
1012
1022
|
}
|
|
1013
1023
|
})()
|
|
1024
|
+
} else if (notification.method === "thread/tokenUsage/updated") {
|
|
1025
|
+
// T06407 増分2 (症状⑥ ドーナツ前後動の根治): App Server の live tokenUsage を
|
|
1026
|
+
// context% の正本にする。**producer(session.seq) 単位**でストアへ記録し (usage.response /
|
|
1027
|
+
// state loop が cwd の権威 producer の seq で参照する)、同時にチャット信号 (overlay 経路) の
|
|
1028
|
+
// context_pct も同じ live 値で更新する。status は上書きしない (このイベントはターン進行中に
|
|
1029
|
+
// 届くため keep-prev。processing/waiting は turn/started・turn/completed が管理する)。
|
|
1030
|
+
const tokenUsage = notification.params?.tokenUsage
|
|
1031
|
+
if (typeof producer_seq === "number" && tokenUsage) {
|
|
1032
|
+
try {
|
|
1033
|
+
recordCodexLiveTokenUsage(producer_seq, tokenUsage)
|
|
1034
|
+
} catch {
|
|
1035
|
+
/* ignore */
|
|
1036
|
+
}
|
|
1037
|
+
// major#3: チャット信号の context/sessionId は権威 producer のときだけ書く。
|
|
1038
|
+
if (isCodexAuthority(cwd, producer_seq)) {
|
|
1039
|
+
const contextPct = codexContextPercentFromTokenUsage(tokenUsage)
|
|
1040
|
+
if (typeof contextPct === "number" && cwd) {
|
|
1041
|
+
try {
|
|
1042
|
+
recordChatActivity(cwd, "codex", { contextPct, sessionId: thread_id })
|
|
1043
|
+
} catch {
|
|
1044
|
+
/* ignore */
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1014
1049
|
}
|
|
1015
1050
|
client.send({ type: "codex.event", stream_id, thread_id, event: notification })
|
|
1016
1051
|
})
|
|
@@ -1032,7 +1067,15 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
1032
1067
|
params,
|
|
1033
1068
|
})
|
|
1034
1069
|
})
|
|
1035
|
-
codexBridge.on("exit", ({ stream_id, thread_id, code, reason }) => {
|
|
1070
|
+
codexBridge.on("exit", ({ stream_id, producer_seq, thread_id, code, reason }) => {
|
|
1071
|
+
// T06407 増分2: この producer(session.seq) の live tokenUsage スロットを破棄する。
|
|
1072
|
+
// seq は immutable なので、reattach で stream_id が付け替わっても alias slot / Map リークは
|
|
1073
|
+
// 生じない (stream_id キー時代の major#1 を解消)。
|
|
1074
|
+
try {
|
|
1075
|
+
forgetCodexLiveTokenUsage(producer_seq)
|
|
1076
|
+
} catch {
|
|
1077
|
+
/* ignore */
|
|
1078
|
+
}
|
|
1036
1079
|
client.send({ type: "codex.exit", stream_id, thread_id, code, reason })
|
|
1037
1080
|
})
|
|
1038
1081
|
codexBridge.on("error", ({ stream_id, thread_id, error }) => {
|
|
@@ -2162,6 +2205,45 @@ export function applyChatOverlay(paneStatus, paneContextPct, chat) {
|
|
|
2162
2205
|
return { status, contextPct }
|
|
2163
2206
|
}
|
|
2164
2207
|
|
|
2208
|
+
/**
|
|
2209
|
+
* T06407 増分2 (codex-review round1 major#3): codex チャット信号 (recordChatActivity) の
|
|
2210
|
+
* patch を組み立てる。status (processing/waiting) は cwd レベルの「ターン進行中か」信号なので
|
|
2211
|
+
* 常に書き、**context_pct / sessionId は権威 producer のときだけ**含める。非権威 producer の
|
|
2212
|
+
* context/sessionId をチャット信号へ書くと、権威 thread の live 未取得の窓で overlay 経由に
|
|
2213
|
+
* 混入し、usage.response (権威 producer ベース) と分岐してドーナツが前後する。
|
|
2214
|
+
*
|
|
2215
|
+
* @param {'processing'|'waiting'} status
|
|
2216
|
+
* @param {number|null} contextPct
|
|
2217
|
+
* @param {string|null} threadId
|
|
2218
|
+
* @param {boolean} isAuthority この producer が cwd の権威 (liveSessionForCwd) か
|
|
2219
|
+
* @returns {{status:string, inputPending:boolean, contextPct?:number, sessionId?:string|null}}
|
|
2220
|
+
*/
|
|
2221
|
+
export function buildCodexChatPatch(status, contextPct, threadId, isAuthority) {
|
|
2222
|
+
const patch = { status, inputPending: false }
|
|
2223
|
+
if (isAuthority) {
|
|
2224
|
+
patch.sessionId = threadId || null
|
|
2225
|
+
if (typeof contextPct === "number") patch.contextPct = contextPct
|
|
2226
|
+
}
|
|
2227
|
+
return patch
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
/**
|
|
2231
|
+
* T06407 増分2 (codex-review round2 major#2): usage.response の codexSessions を、集計に
|
|
2232
|
+
* 使った producer が **今なお cwd の権威**である entry だけへ絞る post-await ガード。
|
|
2233
|
+
* codexSessionUsages の rollout I/O 中に別 session が権威化した cwd の entry (= 旧 producer の
|
|
2234
|
+
* context) を破棄する。破棄された cwd は frontend が state loop の live.context_pct へフォール
|
|
2235
|
+
* バックする (毎 tick 権威を同期解決 = TOCTOU 無し)。
|
|
2236
|
+
*
|
|
2237
|
+
* @param {Array<{cwd:string, contextPercent:number, ...}>} codexSessions codexSessionUsages の結果
|
|
2238
|
+
* @param {Map<string, number>} producerByCwd cwd → 集計に使った producer seq
|
|
2239
|
+
* @param {(cwd:string)=>number|undefined} seqForCwd cwd → 現在の権威 producer seq
|
|
2240
|
+
* @returns {Array} 権威が一致する entry のみ
|
|
2241
|
+
*/
|
|
2242
|
+
export function filterCodexSessionsByAuthority(codexSessions, producerByCwd, seqForCwd) {
|
|
2243
|
+
if (!Array.isArray(codexSessions)) return []
|
|
2244
|
+
return codexSessions.filter((e) => seqForCwd(e.cwd) === producerByCwd.get(e.cwd))
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2165
2247
|
/**
|
|
2166
2248
|
* S1 (2026-07): claude.attach / codex.attach の直後に、その cwd に対応する session.state を
|
|
2167
2249
|
* 1 回だけ即時 push する。startStateLoop の周期 (既定 5s) を待つと、エンジン切替や新規
|
|
@@ -2449,10 +2531,31 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
2449
2531
|
// 分岐の優先順位は decideTurnActiveSource (純関数、単体テスト済み) が権威。
|
|
2450
2532
|
let turnActive = null
|
|
2451
2533
|
// codex 行のときだけ rollout ベース判定の材料を引く (claude 行では常に null)。
|
|
2452
|
-
|
|
2534
|
+
// cwd の**権威 producer session** を 1 度だけ引き、threadId(turn_active 判定用) と
|
|
2535
|
+
// seq(context% の live source 参照キー) の両方を同一 session から取る。これで
|
|
2536
|
+
// usage.response の per-cwd 集計 (同じ liveSessionForCwd) と同じ producer を選び収束する
|
|
2537
|
+
// (axis2)。liveSessionForCwd は会話ターン最新 (lastTurnAtMs) の一意規則。
|
|
2538
|
+
const codexAuthSession =
|
|
2453
2539
|
s.cwd && sessionCliKind === "codex"
|
|
2454
|
-
? codexBridge?.
|
|
2540
|
+
? codexBridge?.liveSessionForCwd?.(s.cwd) || null
|
|
2455
2541
|
: null
|
|
2542
|
+
const codexThreadId = codexAuthSession?.threadId || null
|
|
2543
|
+
// T06407 増分2 (axis3): codex 行の context%(ドーナツ) は TTL 非依存の live store
|
|
2544
|
+
// (thread/tokenUsage/updated 由来) を権威 producer(seq) で引き、chat overlay より優先して
|
|
2545
|
+
// 恒久収束させる。ただし **applyChatOverlay と同じ paneIsProcessing ガードを尊重する**:
|
|
2546
|
+
// 本物の TUI が生成中 (capture-pane が processing = "esc to interrupt" 実在) のときは、
|
|
2547
|
+
// その pane 由来の実 context_pct を stale な live 値で上書きしない (warm chat と Codex TUI
|
|
2548
|
+
// 併存時の TUI ドーナツ破壊を防ぐ。chat overlay は既に上書きを見送っている)。素のシェル
|
|
2549
|
+
// (chat モードのペイン) は processing を返せないため override は従来通り効く。
|
|
2550
|
+
const paneIsProcessing = s.status === "processing"
|
|
2551
|
+
if (
|
|
2552
|
+
sessionCliKind === "codex" &&
|
|
2553
|
+
typeof codexAuthSession?.seq === "number" &&
|
|
2554
|
+
!paneIsProcessing
|
|
2555
|
+
) {
|
|
2556
|
+
const liveCtx = codexLiveContextForProducer(codexAuthSession.seq)
|
|
2557
|
+
if (typeof liveCtx === "number") contextPct = liveCtx
|
|
2558
|
+
}
|
|
2456
2559
|
const turnActiveSource = decideTurnActiveSource({
|
|
2457
2560
|
chatStatus: chat?.status,
|
|
2458
2561
|
cwd: s.cwd,
|
|
@@ -4529,9 +4632,19 @@ async function dispatch(msg, ctx) {
|
|
|
4529
4632
|
contextPct: 0,
|
|
4530
4633
|
}).catch(() => {})
|
|
4531
4634
|
} else {
|
|
4532
|
-
|
|
4635
|
+
// 増分2: context% は cwd の権威 producer(seq) の live を正本にする (resume 直後の
|
|
4636
|
+
// このセッションは lastTurnAtMs=now で権威。live 未取得なら rollout フォールバック)。
|
|
4637
|
+
const resumeProducerSeq = ctx.codexBridge?.liveSessionForCwd?.(resolvedCwd)?.seq
|
|
4638
|
+
codexUsageForThread(info.threadId, { producerSeq: resumeProducerSeq })
|
|
4533
4639
|
.catch(() => null)
|
|
4534
4640
|
.then((usage) => {
|
|
4641
|
+
// ⚠️ codex-review round2 major#2: rollout I/O 中に別 session が権威化しうる。
|
|
4642
|
+
// await 後に権威 seq を再検証し、この resume session が現権威でなければ即時 push を
|
|
4643
|
+
// 見送る (古い context/sessionId を書かない。state loop が ≤5s で正す)。
|
|
4644
|
+
const stillAuthority =
|
|
4645
|
+
resumeProducerSeq != null &&
|
|
4646
|
+
ctx.codexBridge?.liveSessionForCwd?.(resolvedCwd)?.seq === resumeProducerSeq
|
|
4647
|
+
if (!stillAuthority) return undefined
|
|
4535
4648
|
const contextPct = typeof usage?.context === "number" ? usage.context : 0
|
|
4536
4649
|
recordChatActivity(resolvedCwd, "codex", {
|
|
4537
4650
|
status: "waiting",
|
|
@@ -5414,14 +5527,53 @@ async function dispatch(msg, ctx) {
|
|
|
5414
5527
|
let codexSessions = []
|
|
5415
5528
|
if (ctx.codexBridge && ctx.codexBridge.sessions.size > 0) {
|
|
5416
5529
|
try {
|
|
5417
|
-
|
|
5418
|
-
|
|
5419
|
-
|
|
5530
|
+
// T06407 増分2 (axis2): per-cwd の context% は **cwd ごとに権威 producer 1 件**へ
|
|
5531
|
+
// 集約する。旧実装は全 live session を codexSessions へ流していたため、同一 cwd に
|
|
5532
|
+
// 複数 thread があると frontend の usageByCwd (updatedAtMs 同値で first-wins) が
|
|
5533
|
+
// 任意のスレッドを拾い、state loop (権威 producer) と別 thread を選んでドーナツが
|
|
5534
|
+
// 前後した。liveSessionForCwd の同一規則で cwd → 権威 producer(session) を一意化し、
|
|
5535
|
+
// producerSeq を context% の live source として通して両経路を収束させる。
|
|
5536
|
+
const cwds = new Set()
|
|
5537
|
+
for (const s of ctx.codexBridge.sessions.values()) {
|
|
5538
|
+
if (s.threadId && s.cwd) cwds.add(s.cwd)
|
|
5539
|
+
}
|
|
5540
|
+
const liveCodex = []
|
|
5541
|
+
for (const cwd of cwds) {
|
|
5542
|
+
const authoritative = ctx.codexBridge.liveSessionForCwd?.(cwd)
|
|
5543
|
+
if (authoritative?.threadId) {
|
|
5544
|
+
liveCodex.push({
|
|
5545
|
+
sessionId: authoritative.threadId,
|
|
5546
|
+
cwd: authoritative.cwd,
|
|
5547
|
+
threadId: authoritative.threadId,
|
|
5548
|
+
producerSeq: authoritative.seq,
|
|
5549
|
+
})
|
|
5550
|
+
}
|
|
5551
|
+
}
|
|
5420
5552
|
if (liveCodex.length > 0) {
|
|
5421
5553
|
// 5h/7d はアカウント全体の値 (どの live session から読んでも同じ契約の残量)
|
|
5422
|
-
// なので、代表として最初の 1
|
|
5423
|
-
codexUsage = await codexUsageForThread(liveCodex[0].threadId
|
|
5554
|
+
// なので、代表として最初の 1 件から取る (context% はその producer の live を使う)。
|
|
5555
|
+
codexUsage = await codexUsageForThread(liveCodex[0].threadId, {
|
|
5556
|
+
producerSeq: liveCodex[0].producerSeq,
|
|
5557
|
+
})
|
|
5424
5558
|
codexSessions = await codexSessionUsages(liveCodex)
|
|
5559
|
+
// ⚠️ codex-review round2 major#2: codexSessionUsages の rollout I/O 中に cwd の
|
|
5560
|
+
// 権威 producer が別 session へ移りうる。await 後に各 cwd の現権威 seq を再検証し、
|
|
5561
|
+
// 集計に使った producer が現権威でない entry は破棄する (古い producer の context を
|
|
5562
|
+
// codexCtxByCwd へ出さない)。破棄した cwd は frontend が state loop の
|
|
5563
|
+
// live.context_pct (毎 tick 権威を同期解決=TOCTOU 無し) にフォールバックする。
|
|
5564
|
+
const seqForCwd = (cwd) => ctx.codexBridge.liveSessionForCwd?.(cwd)?.seq
|
|
5565
|
+
const producerByCwd = new Map(liveCodex.map((e) => [e.cwd, e.producerSeq]))
|
|
5566
|
+
codexSessions = filterCodexSessionsByAuthority(
|
|
5567
|
+
codexSessions,
|
|
5568
|
+
producerByCwd,
|
|
5569
|
+
seqForCwd,
|
|
5570
|
+
)
|
|
5571
|
+
// codex-review round3 minor#1: 集約 codexUsage.context は代表 producer
|
|
5572
|
+
// (liveCodex[0]) の値。await 中に代表が権威を失っていたら旧 context を出さない
|
|
5573
|
+
// (context だけ null 化。5h/7d は account-wide なので保持する)。
|
|
5574
|
+
if (codexUsage && seqForCwd(liveCodex[0].cwd) !== liveCodex[0].producerSeq) {
|
|
5575
|
+
codexUsage = { ...codexUsage, context: null }
|
|
5576
|
+
}
|
|
5425
5577
|
}
|
|
5426
5578
|
} catch (err) {
|
|
5427
5579
|
ctx.logger?.debug?.({ err: err?.message }, "codex usage snapshot skipped")
|
package/src/usage.mjs
CHANGED
|
@@ -1594,29 +1594,125 @@ export function classifyCodexRateLimitWindows(snapshot) {
|
|
|
1594
1594
|
return result
|
|
1595
1595
|
}
|
|
1596
1596
|
|
|
1597
|
+
// ---------------------------------------------------------------------------
|
|
1598
|
+
// Codex context%(ドーナツ)の live source(T06407 増分2, 症状⑥ ドーナツ前後動の根治)
|
|
1599
|
+
//
|
|
1600
|
+
// 従来 context% は「rollout の token_count を state loop / 60 秒ポーリングが**別々に**読む」
|
|
1601
|
+
// 二重ソースで、cadence・キー(cwd/threadId)・rollout 書込ラグの差で値が前後していた。
|
|
1602
|
+
// App Server の `thread/tokenUsage/updated`(ターン中 live push)を正本にし、rollout を最終
|
|
1603
|
+
// フォールバックへ格下げすることで両経路を同一 live 値へ収束させる。
|
|
1604
|
+
//
|
|
1605
|
+
// ⚠️ store のキーは **producer(session) の immutable seq**(codex-stream-bridge の
|
|
1606
|
+
// `session.seq`)にする。context% は thread ではなく **producer(session) の属性**であり、
|
|
1607
|
+
// 以下の理由で threadId でも stream_id でもキーにできない(codex-review round1 の指摘 3 点):
|
|
1608
|
+
// - stream_id は購読端末 ID で reattach/detach で付け替わる → alias slot が残り Map リーク、
|
|
1609
|
+
// exit は現 primary しか消せず stale が残る(指摘1)。
|
|
1610
|
+
// - 同一 thread に複数 producer(resume) がありうるので threadId 単独では混線する。
|
|
1611
|
+
// - 「受信時刻 latest を代表」は旧 producer の遅延通知が後着時刻で再び代表を奪う(指摘2)。
|
|
1612
|
+
// 代表の選択は **cwd の権威 producer**(liveSessionForCwd の seq)を呼び出し側が渡して引く
|
|
1613
|
+
// (codexLiveContextForProducer(seq))。受信時刻に依存しないので前後動しない。
|
|
1614
|
+
//
|
|
1615
|
+
// ⚠️ tokenUsage の shape は App Server wire = **camelCase**(total / last:{totalTokens,...} /
|
|
1616
|
+
// modelContextWindow)。rollout jsonl の last_token_usage は snake_case で、そちらは
|
|
1617
|
+
// codexUsageForThread 内の従来ロジックが扱う。
|
|
1618
|
+
//
|
|
1619
|
+
// @type {Map<number, { tokenUsage: object, atMs: number }>}
|
|
1620
|
+
const _codexLiveTokenUsageByProducer = new Map()
|
|
1621
|
+
|
|
1622
|
+
/** `thread/tokenUsage/updated` を受けて **producer(session.seq) 単位**で live tokenUsage を
|
|
1623
|
+
* 記録する。
|
|
1624
|
+
* @param {number} producerSeq codex-stream-bridge の immutable な session.seq
|
|
1625
|
+
* @param {object} tokenUsage ThreadTokenUsage (camelCase)
|
|
1626
|
+
* @param {{ atMs?: number }} [opts]
|
|
1627
|
+
* atMs 単調ガードで、同一 producer 内で順序前後した古い更新を捨てる。 */
|
|
1628
|
+
export function recordCodexLiveTokenUsage(producerSeq, tokenUsage, opts = {}) {
|
|
1629
|
+
if (typeof producerSeq !== "number" || !Number.isFinite(producerSeq) || !tokenUsage) return
|
|
1630
|
+
const atMs = typeof opts.atMs === "number" ? opts.atMs : Date.now()
|
|
1631
|
+
const prev = _codexLiveTokenUsageByProducer.get(producerSeq)
|
|
1632
|
+
// 同一 producer 内の古い更新(atMs が後退)は捨てる。
|
|
1633
|
+
if (prev && typeof prev.atMs === "number" && atMs < prev.atMs) return
|
|
1634
|
+
_codexLiveTokenUsageByProducer.set(producerSeq, { tokenUsage, atMs })
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/** セッション終了時に producer(session.seq) の live tokenUsage を破棄する。seq は immutable
|
|
1638
|
+
* なので alias slot / リークは生じない(stream_id キー時代の指摘1を解消)。 */
|
|
1639
|
+
export function forgetCodexLiveTokenUsage(producerSeq) {
|
|
1640
|
+
if (typeof producerSeq !== "number") return
|
|
1641
|
+
_codexLiveTokenUsageByProducer.delete(producerSeq)
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/** テスト用: live tokenUsage ストアをクリアする。 */
|
|
1645
|
+
export function _resetCodexLiveTokenUsage() {
|
|
1646
|
+
_codexLiveTokenUsageByProducer.clear()
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/** camelCase の ThreadTokenUsage から context%(占有率) を算出する。分母(窓)や last が
|
|
1650
|
+
* 無ければ null。context 窓は last(直近ターンで実際に窓へ載った分)で測る (total は会話累積で
|
|
1651
|
+
* 100% に張り付くため。rollout 版と同じ設計)。 */
|
|
1652
|
+
export function codexContextPercentFromTokenUsage(tokenUsage) {
|
|
1653
|
+
const windowSize = tokenUsage?.modelContextWindow
|
|
1654
|
+
const last = tokenUsage?.last
|
|
1655
|
+
if (!last || typeof windowSize !== "number" || windowSize <= 0) return null
|
|
1656
|
+
const tokens =
|
|
1657
|
+
typeof last.totalTokens === "number"
|
|
1658
|
+
? last.totalTokens
|
|
1659
|
+
: (last.inputTokens || 0) + (last.cachedInputTokens || 0) + (last.outputTokens || 0)
|
|
1660
|
+
if (typeof tokens !== "number") return null
|
|
1661
|
+
return Math.min(100, Math.round((tokens / windowSize) * 1000) / 10)
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/** producer(session.seq) の live context%(App Server 正本)。呼び出し側は cwd の権威 producer
|
|
1665
|
+
* (liveSessionForCwd の seq) を渡す。live tokenUsage 未取得 / 算出不能なら null(呼び出し側が
|
|
1666
|
+
* rollout へフォールバックする)。 */
|
|
1667
|
+
export function codexLiveContextForProducer(producerSeq) {
|
|
1668
|
+
if (typeof producerSeq !== "number") return null
|
|
1669
|
+
const e = _codexLiveTokenUsageByProducer.get(producerSeq)
|
|
1670
|
+
if (!e) return null
|
|
1671
|
+
return codexContextPercentFromTokenUsage(e.tokenUsage)
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1597
1674
|
/**
|
|
1598
1675
|
* 指定 threadId の Codex セッションから usage (5h/7d + context%) を返す。
|
|
1599
1676
|
* Claude の `getUsage()` と同じ shape (`UsageStats`) を返すため、frontend / wire
|
|
1600
1677
|
* protocol の分岐を増やさずに済む。
|
|
1601
1678
|
*
|
|
1679
|
+
* context%(ドーナツ) は **呼び出し側が渡す権威 producer の live tokenUsage** を正本とし
|
|
1680
|
+
* (codexLiveContextForProducer)、無ければ rollout の last_token_usage にフォールバックする
|
|
1681
|
+
* (T06407 増分2)。これで state loop / usage.response の両経路が同一 live 値へ収束し、
|
|
1682
|
+
* ドーナツの前後動 (症状⑥) が解消する。producerSeq を渡さない旧経路は rollout のみ。
|
|
1683
|
+
*
|
|
1602
1684
|
* ⚠️ `last5h`/`last7d` は Codex 側アカウント全体の rate limit(ChatGPT/OpenAI
|
|
1603
1685
|
* サブスクリプション)であり、Claude の `last5h`/`last7d`(Anthropic サブスク)とは
|
|
1604
1686
|
* **別契約の別数値**。呼び出し側で Claude usage と単純合算/上書きしないこと
|
|
1605
1687
|
* (どちらのアカウントの残量か分からなくなる)。
|
|
1606
1688
|
*
|
|
1607
1689
|
* @param {string} threadId
|
|
1690
|
+
* @param {{ producerSeq?: number }} [opts] producerSeq: この thread を表示する cwd の権威
|
|
1691
|
+
* producer(session.seq)。context% の live source として使う。
|
|
1608
1692
|
* @returns {Promise<import("./usage.mjs").UsageStats|null>} 見つからなければ null
|
|
1609
1693
|
*/
|
|
1610
|
-
export async function codexUsageForThread(threadId) {
|
|
1694
|
+
export async function codexUsageForThread(threadId, opts = {}) {
|
|
1611
1695
|
if (!threadId) return null
|
|
1612
1696
|
const fp = await findCodexRolloutFile(threadId)
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1697
|
+
const found = fp ? await latestTokenCountFromRollout(fp) : null
|
|
1698
|
+
// 増分2: context% は権威 producer の App Server live tokenUsage を正本にする。
|
|
1699
|
+
// ⚠️ codex-review round2 major#2: live は rollout I/O の **後** に読む。I/O 中に起きた同一
|
|
1700
|
+
// producer の token 更新を反映し、I/O 中に producer が exit していれば live は null になって
|
|
1701
|
+
// rollout フォールバックへ正しく落ちる(I/O 前スナップショットだと exit 済み producer の古い
|
|
1702
|
+
// context を返してしまう)。呼び出し側はさらに await 後に権威 seq を再検証する。
|
|
1703
|
+
// ⚠️ live が取れていれば rollout 不在(新スレッド / 書込ラグ中)でも usage を返す。旧実装は
|
|
1704
|
+
// rollout 不在で early-return null し、rollout が書かれるまで live-only の context を
|
|
1705
|
+
// usage.response / codexSessions へ出せなかった。rate limit は rollout 由来のため rollout
|
|
1706
|
+
// 不在時は「未提供」になる(context だけ live で出す)。
|
|
1707
|
+
const liveContext =
|
|
1708
|
+
typeof opts.producerSeq === "number" ? codexLiveContextForProducer(opts.producerSeq) : null
|
|
1709
|
+
// live も rollout も無ければ従来どおり null。
|
|
1710
|
+
if (liveContext === null && !found) return null
|
|
1711
|
+
const info = found?.payload?.info
|
|
1712
|
+
const rateLimits = found?.payload?.rate_limits ?? null
|
|
1713
|
+
|
|
1714
|
+
// live tokenUsage が取れていれば context の正本にし、rollout では上書きしない。
|
|
1715
|
+
let contextPercent = liveContext
|
|
1620
1716
|
const windowSize = info?.model_context_window
|
|
1621
1717
|
// context 窓の「現在の占有率」は last_token_usage(直近ターンで実際に窓へ載った
|
|
1622
1718
|
// トークン)で測る。total_token_usage は会話累積(キャッシュ再利用分も加算し
|
|
@@ -1625,8 +1721,14 @@ export async function codexUsageForThread(threadId) {
|
|
|
1625
1721
|
// 窓 258,400 = 519% → 100% 張り付き。実占有は last=66,973 → 25.9%)。Codex は
|
|
1626
1722
|
// 自動コンパクションで実文脈を使い回すため、累積で割ると必ず張り付く。
|
|
1627
1723
|
// last_token_usage が無い旧 Codex 版のみ従来の total_token_usage にフォールバック。
|
|
1724
|
+
// ⚠️ live tokenUsage が取れていれば (contextPercent!==null) rollout では上書きしない。
|
|
1628
1725
|
const usageForContext = info?.last_token_usage ?? info?.total_token_usage
|
|
1629
|
-
if (
|
|
1726
|
+
if (
|
|
1727
|
+
contextPercent === null &&
|
|
1728
|
+
usageForContext &&
|
|
1729
|
+
typeof windowSize === "number" &&
|
|
1730
|
+
windowSize > 0
|
|
1731
|
+
) {
|
|
1630
1732
|
const tokens =
|
|
1631
1733
|
typeof usageForContext.total_tokens === "number"
|
|
1632
1734
|
? usageForContext.total_tokens
|
|
@@ -1652,11 +1754,15 @@ export async function codexUsageForThread(threadId) {
|
|
|
1652
1754
|
}
|
|
1653
1755
|
|
|
1654
1756
|
/**
|
|
1655
|
-
* 複数の Codex セッション (threadId + cwd + sessionId) から `SessionUsageEntry[]`
|
|
1757
|
+
* 複数の Codex セッション (threadId + cwd + sessionId + producerSeq) から `SessionUsageEntry[]`
|
|
1656
1758
|
* (Claude の `getSessionUsages()` と同 shape) を返す。見つからなかった/context 不明な
|
|
1657
1759
|
* セッションは結果から除外する (Claude 版と同じ「分かるものだけ返す」約束)。
|
|
1658
1760
|
*
|
|
1659
|
-
*
|
|
1761
|
+
* ⚠️ 呼び出し側 (usage.response) は cwd ごとに **権威 producer 1 件**へ集約して渡す。
|
|
1762
|
+
* producerSeq を context% の live source として codexUsageForThread へ通し、state loop と
|
|
1763
|
+
* 同一の live 値へ収束させる (T06407 増分2, axis2)。
|
|
1764
|
+
*
|
|
1765
|
+
* @param {Array<{sessionId: string, cwd: string, threadId: string, producerSeq?: number}>} liveSessions
|
|
1660
1766
|
* @returns {Promise<Array<{sessionId: string, cwd: string, contextPercent: number, updatedAtMs: number}>>}
|
|
1661
1767
|
*/
|
|
1662
1768
|
export async function codexSessionUsages(liveSessions) {
|
|
@@ -1664,9 +1770,9 @@ export async function codexSessionUsages(liveSessions) {
|
|
|
1664
1770
|
const now = Date.now()
|
|
1665
1771
|
const out = []
|
|
1666
1772
|
await Promise.all(
|
|
1667
|
-
liveSessions.map(async ({ sessionId, cwd, threadId }) => {
|
|
1773
|
+
liveSessions.map(async ({ sessionId, cwd, threadId, producerSeq }) => {
|
|
1668
1774
|
if (!threadId || !cwd) return
|
|
1669
|
-
const usage = await codexUsageForThread(threadId)
|
|
1775
|
+
const usage = await codexUsageForThread(threadId, { producerSeq })
|
|
1670
1776
|
if (!usage || typeof usage.context !== "number") return
|
|
1671
1777
|
out.push({
|
|
1672
1778
|
sessionId: sessionId || threadId,
|