From 0c8ddfed80cc5024f93efdfcfeb0a86a3dfe4d1e Mon Sep 17 00:00:00 2001 From: takecchi Date: Wed, 12 Aug 2026 00:24:07 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20Codex=20=E3=82=BB=E3=83=83=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E3=81=AE=E3=83=A2=E3=83=87=E3=83=AB=E3=82=92?= =?UTF-8?q?=E4=B8=80=E8=A6=A7=E3=81=AB=E8=A1=A8=E7=A4=BA=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `codex exec --json` は解決済みモデルを一切運ばない(実測 codex-cli 0.147.0)ため、 `--model` を明示していないセッション(= CLI の既定に任せている)のモデル欄が一覧で 空のままだった。`--model` を明示した場合は #109 で表示されるので、埋まらないのは 既定で動いているときだけ。 `codex debug models` に既定を示す印は無く、`codex doctor --json` も `""` と しか答えない。CLI が解決結果を書き残すのは rollout の `turn_context` だけなので、 そこを読む: - `core/codex-rollout.ts`(新規・純粋): rollout ファイル名の突き合わせと `turn_context.model` の抽出 - `utils/codex.ts` の `resolveCodexRolloutModel`: `$CODEX_HOME/sessions/**` の探索と 先頭 512KB の読み出し(1 ファイルは平均 1MB 超)。`turn_context` はターン開始時に 書かれるので数回だけリトライし、読めなければ黙って諦める - `core/agent-events.ts` に `model_resolved` を追加: 答えはターンが終わったあとに 届くことがあり、`session_started` / `assistant_message` に相乗りさせると `status` が `running` へ巻き戻るため、モデル欄だけを触る中立イベントにした - `core/codex-adapter.ts`: `thread.started` で問い合わせを始め、ストリームは止めずに イベントの合間へ流す(短いターンで取りこぼさないよう終端イベントの後でも回収する) カタログ先頭(priority 1)を既定とみなす当て推量は採らない。実測では一致したが、 `~/.codex/config.toml` で `model` を設定しているユーザーに嘘のモデル名を出すため。 --- .claude/rules/sdk-integration.md | 14 +++- CLAUDE.md | 3 +- docs/TASKS.md | 13 +++ docs/TECH_NOTES.md | 38 +++++++++ src/bootstrap/build-manager.ts | 4 + src/core/agent-events.spec.ts | 34 ++++++++ src/core/agent-events.ts | 14 ++++ src/core/codex-adapter.spec.ts | 139 +++++++++++++++++++++++++++++++ src/core/codex-adapter.ts | 66 +++++++++++++-- src/core/codex-rollout.spec.ts | 87 +++++++++++++++++++ src/core/codex-rollout.ts | 86 +++++++++++++++++++ src/core/index.ts | 1 + src/utils/codex.spec.ts | 78 ++++++++++++++++- src/utils/codex.ts | 122 +++++++++++++++++++++++++++ 14 files changed, 690 insertions(+), 9 deletions(-) create mode 100644 src/core/codex-rollout.spec.ts create mode 100644 src/core/codex-rollout.ts diff --git a/.claude/rules/sdk-integration.md b/.claude/rules/sdk-integration.md index 1434e29..47bdc2c 100644 --- a/.claude/rules/sdk-integration.md +++ b/.claude/rules/sdk-integration.md @@ -36,8 +36,20 @@ - **3 点セットは provider ごとに対で書く**。Claude が `claude-adapter.ts` / `claude-parse.ts` / `claude-errors.ts` なら、Codex は `codex-adapter.ts` / `codex-parse.ts` / `codex-errors.ts` - (+ JSONL の型と受理ガードだけを持つ `codex-events.ts`、実 I/O の `utils/codex.ts`)。 + (+ JSONL の型と受理ガードだけを持つ `codex-events.ts`、rollout の形を知る + `codex-rollout.ts`、実 I/O の `utils/codex.ts`)。 **その CLI / SDK の形の知識をこの外へ漏らさない。** +- **その provider が「実際に動いているモデル」を報告しないなら、推測で埋めない。** + `codex exec --json` はモデル名を一切運ばない(実測 0.147.0)。`--model` を明示していない + セッションのモデル名は、CLI が書き残す rollout の `turn_context` からしか分からないので、 + そこを読む(`core/codex-rollout.ts` = 純粋な抽出 / `utils/codex.ts` の + `resolveCodexRolloutModel` = 探索と読み出し。理由と実測は + [docs/TECH_NOTES.md](../../docs/TECH_NOTES.md))。**カタログの先頭を既定とみなす類の + 当て推量はしない** — 設定で既定を変えているユーザーに嘘のモデル名を出すことになる。 + 取れなければモデル欄は空のままでよい(`permissions: false` と同じで、無いものは無いと出す)。 + 報告は **`model_resolved`**(`core/agent-events.ts`)で行う。答えはターンが終わったあとに + 届くことがあり、`session_started` / `assistant_message` に相乗りさせると `status` が + `running` に巻き戻るため、**モデル欄だけを触る専用イベント**を通す。 - **CLI は同梱せず、ユーザーがインストールしたものを起動する**(`git` / `gh` と同じ扱い)。 provider の SDK パッケージを依存に足すと、その provider を使わないユーザーにまで プラットフォーム別バイナリを配ることになる。認証もその CLI のログインに委ねる diff --git a/CLAUDE.md b/CLAUDE.md index 6b32b18..22ee7ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ CI(`.github/workflows/ci.yml`)は `lint → typecheck → test → build`。 | やりたいこと | 主なファイル | |---|---| | セッションの状態・遷移 | `core/types.ts`(union)/ `core/status-meta.ts`(性質の表)/ `core/status-reducer.ts`(純粋 reducer) | -| 別のエージェント(Codex / Grok)に対応させる | `core/agent-ports.ts`(`AgentAdapter` / `AgentCapabilities` / `PermissionDecision` = DI 境界)/ `core/agent-events.ts`(`AgentEvent` の語彙 + 全 provider 共通の畳み込み `applyAgentEvent`)/ `core/claude-adapter.ts`・`core/claude-parse.ts`・`core/claude-errors.ts`(Claude 実装の 3 点セット)/ `core/codex-adapter.ts`・`core/codex-parse.ts`・`core/codex-errors.ts` + `core/codex-events.ts`(JSONL の型)・`core/codex-models.ts`・`utils/codex.ts`(`codex exec` の起動 = 唯一の I/O)/ アダプタの登録は `bootstrap/build-manager.ts` の `buildAgents` | +| 別のエージェント(Codex / Grok)に対応させる | `core/agent-ports.ts`(`AgentAdapter` / `AgentCapabilities` / `PermissionDecision` = DI 境界)/ `core/agent-events.ts`(`AgentEvent` の語彙 + 全 provider 共通の畳み込み `applyAgentEvent`)/ `core/claude-adapter.ts`・`core/claude-parse.ts`・`core/claude-errors.ts`(Claude 実装の 3 点セット)/ `core/codex-adapter.ts`・`core/codex-parse.ts`・`core/codex-errors.ts` + `core/codex-events.ts`(JSONL の型)・`core/codex-models.ts`・`core/codex-rollout.ts`(rollout から解決済みモデル)・`utils/codex.ts`(`codex exec` の起動 = 唯一の I/O)/ アダプタの登録は `bootstrap/build-manager.ts` の `buildAgents` | | SDK メッセージの解釈 | `core/claude-parse.ts` **のみ**(`parseClaudeMessage`: SDKMessage → `AgentEvent[]`)+ `core/__fixtures__/*.jsonl`。Codex は `core/codex-parse.ts`(`parseCodexEvent`: `codex exec --json` の JSONL → `AgentEvent[]`)+ `core/__fixtures__/codex-*.jsonl` | | エージェントの切替(`/agent`)| `core/session-manager.ts`(一覧=既定: `getDefaultAgentId` / `setDefaultAgent`・詳細=切替: `listAgents` / `getSessionAgent` / `setSessionAgent`)/ `ui/agent-select.tsx`(`mode:'default'`=一覧 / `'session'`=詳細)/ `core/status-reducer.ts` の `agent_switched` | | エージェントの導入・ログイン検出 | `core/agent-ports.ts` の `AgentAdapter.checkAvailability` / `AgentAvailability` / `core/agent-availability.ts`(`resolveDefaultAgentId` / `noAgentInstalled`・純粋)/ `utils/claude.ts` の `detectClaudeAvailability`・`utils/codex.ts` の `detectCodexAvailability`(実 I/O)/ `SessionManager.checkAgents`(集約・キャッシュ)/ `ui/hooks.ts` の `useAgentAvailability` | @@ -87,6 +87,7 @@ CI(`.github/workflows/ci.yml`)は `lint → typecheck → test → build`。 | 通知 | `core/notify.ts`(判定・純粋)/ `utils/notify.ts`(OS I/O) | | 学習データ利用の警告 | `core/privacy.ts`(判定・純粋)/ `utils/privacy.ts`(キャッシュ+非公開 API)/ `ui/banner.tsx` | | モデル選択 | `core/models.ts` / `utils/model-catalog.ts`(Claude)/ `core/codex-models.ts` + `utils/codex.ts` の `fetchCodexModelCatalog`(Codex)/ `ui/model-select.tsx` | +| 一覧に出す「実際に動いているモデル」 | Claude は `system/init` が運ぶ。**Codex は JSONL に無い**ので `core/codex-rollout.ts`(純粋な抽出)+ `utils/codex.ts` の `resolveCodexRolloutModel`(rollout の `turn_context` を読む)→ `AgentEvent` の `model_resolved`(status を触らない専用イベント)。理由は docs/TECH_NOTES.md | | 選択肢リストの表示(質問・モデル) | `core/choice-lines.ts`(折返し + クリック逆算 `choiceRowHeights`/`choiceIndexAtRow`・純粋)/ `ui/choice-row.tsx`(1件の描画) | | アップデート通知・`/update` | `core/update.ts`(比較・判定・DI 境界)/ `utils/update.ts`(registry fetch・経路判定・`npm install`)/ `ui/update-dialog.tsx` | | 子プロセスへ渡す env(`NODE_ENV` を漏らさない) | `core/child-env.ts`(`childEnv` = 純粋)/ `utils/child-env.ts`(`childProcessEnv` = 実 `process.env`)/ `utils/claude-query.ts`(SDK の `query` に `Options.env` を被せた入口)。**プロセスを起こすときは必ず通す**(issue #103) | diff --git a/docs/TASKS.md b/docs/TASKS.md index d2242f0..a4bcab0 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -1183,7 +1183,20 @@ zsh: abort codiva resume の引き回し・systemPrompt の前置・中断・終端イベント無しの終了)/ `utils/codex.spec.ts` (引数の組み立て) +- [x] **解決済みモデルの表示**(一覧のモデル欄): `codex exec --json` はモデル名を運ばないので、 + `--model` を明示していないセッション(= CLI の既定)のモデル欄が空のままだった。 + rollout(`$CODEX_HOME/sessions/<年>/<月>/<日>/rollout-<時刻>-.jsonl`)の + `turn_context.model` が唯一の出所なのでそこを読む(`core/codex-rollout.ts` = 純粋な抽出 / + `utils/codex.ts` の `resolveCodexRolloutModel` = 探索と読み出し)。報告は専用の + `model_resolved`(status を触らない中立イベント) + > 実績メモ: +> - **解決済みモデルは JSONL に無い**(実測 0.147.0)。`codex debug models` に既定の印は無く、 +> `codex doctor --json` も `""` としか答えない。rollout の `turn_context` だけが +> 実際の slug(`-m` 無しで `gpt-5.6-sol`)を持つ。**カタログ先頭を既定とみなす当て推量は +> 採らない** — `~/.codex/config.toml` で既定を変えているユーザーに嘘のモデル名を出すため。 +> 問い合わせの答えは**ターンが終わったあとに届くことがある**ので、`session_started` / +> `assistant_message` に相乗りさせず(あれは `status` を `running` へ戻す)専用イベントにした。 > - セルフレビューで塞いだ取りこぼし: 初回のターンが `thread.started` 前に落ちると > systemPrompt を二度と渡せなくなる(latch をやめて `threadId` から導出)/ 捨てられた run が > `codex exec` を孤児として残す(Rust は `SIGPIPE` を無視するので明示的に kill)/ `stderr` の diff --git a/docs/TECH_NOTES.md b/docs/TECH_NOTES.md index aa3f7ed..5ddfec2 100644 --- a/docs/TECH_NOTES.md +++ b/docs/TECH_NOTES.md @@ -1038,6 +1038,44 @@ codex exec --json --skip-git-repo-check エイリアスが無い。したがって取得に失敗したときのフォールバックは**「デフォルト」1 行だけ** (`DEFAULT_ONLY_MODEL_OPTIONS`)。ここに推測でモデル名を並べると必ず陳腐化する。 +### 解決済みモデルは JSONL に無い — rollout の `turn_context` が唯一の出所(実測 0.147.0) + +Claude の `system/init` に当たるもの(**実際に動いているモデル名**)が Codex には無い。 +実バイナリで確かめた結果は次のとおり: + +| 調べた先 | 結果 | +|---|---| +| `codex exec --json` の stdout | **モデル名を一切運ばない**(`thread.started` は `thread_id` だけ、`turn.started` は空、`turn.completed` は usage だけ) | +| `codex debug models` | 既定を示す印が**無い**(`priority` はあるが「CLI の既定」とは別物) | +| `codex doctor --json` | `config.load.details.model` は `""` としか答えない | +| rollout の `session_meta` | `model_provider`(`"openai"`)だけで **slug は無い** | +| rollout の **`turn_context`** | **`model: "gpt-5.6-sol"`** ← ここだけが解決済み slug を持つ | + +そのため `--model` を明示していないセッション(= CLI の既定に任せている)のモデル欄が +一覧で空のままだった。`--model` を渡した場合は `Session.consume()` が渡した値をそのまま +表示する(#109)ので、埋まらないのは**既定で動いているとき**だけ。 + +対策は rollout を読むこと(`core/codex-rollout.ts` = 純粋な抽出 / `utils/codex.ts` の +`resolveCodexRolloutModel` = 探索と読み出し): + +- 置き場所は `$CODEX_HOME`(既定 `~/.codex`)の + `sessions/<年>/<月>/<日>/rollout-<時刻>-.jsonl`。ファイル名の**末尾が + `thread.started` の `thread_id`** なので、時刻部分(ローカル時刻)は当てにせず id で突き合わせる。 +- 1 ファイルは実測**平均 1MB 超**(`session_meta` が `base_instructions` 全文を 1 行で運ぶ)。 + `turn_context` はその数行あとに来るので**先頭 512KB だけ**読む。 +- `turn_context` は**ターン開始時**に書かれるので、`thread.started` の直後にはまだ無いことが + ある。数回だけ間を置いて読み直す(実測では当日ディレクトリに当たって **4ms** で解決した)。 +- **カタログ先頭(priority 1)を既定とみなす手は採らない。** 実測では確かに + priority 1 = `gpt-5.6-sol` = 実際の既定だったが、`~/.codex/config.toml` で `model` を + 設定しているユーザーには**嘘のモデル名**を出すことになる。 +- 読めなければ黙って諦める(モデル欄が空のままになるだけ)。`--ephemeral` 実行や + レイアウト変更で取れなくてもセッションは壊さない。 + +報告は専用の中立イベント **`model_resolved`**(`core/agent-events.ts`)で行う。 +`session_started` / `assistant_message` にも `model` は載るが、あちらは「ターンが動いている」 +区切りでもあり `status` を `running` へ戻す。問い合わせの答えは**ターンが終わったあとに +届くことがある**ので、あれに相乗りさせると完了したセッションが `running` に巻き戻る。 + ### フィクスチャの採取: モック Responses API を立てて実バイナリを走らせる Codex の JSONL には上流に採取済みのフィクスチャが無く、実アカウントで走らせると diff --git a/src/bootstrap/build-manager.ts b/src/bootstrap/build-manager.ts index 4d6cb1f..4d78def 100644 --- a/src/bootstrap/build-manager.ts +++ b/src/bootstrap/build-manager.ts @@ -23,6 +23,7 @@ import { lookupPrs, markPrReady, notify, + resolveCodexRolloutModel, saveRepoPrompt, spawnCodex, spawnLogin, @@ -89,6 +90,9 @@ export function buildAgents( networkAccess: config.codexNetworkAccess, generateTitle, checkAvailability: () => detectCodexAvailability(), + // `codex exec --json` は解決済みモデルを運ばない。既定モデルで動いている + // セッションのモデル名は rollout の `turn_context` からしか分からない。 + resolveModel: (threadId) => resolveCodexRolloutModel(threadId), spawnLogin, }), }; diff --git a/src/core/agent-events.spec.ts b/src/core/agent-events.spec.ts index afa03c3..8311300 100644 --- a/src/core/agent-events.spec.ts +++ b/src/core/agent-events.spec.ts @@ -51,6 +51,40 @@ describe('applyAgentEvent / session_started', () => { }); }); +describe('applyAgentEvent / model_resolved', () => { + // Codex は解決済みモデルをストリームで運ばないので、別経路で調べた結果が + // **ターンが終わったあとに**届くことがある。だから status を触らせない。 + it('fills in the model without touching the status of a finished session', () => { + const finished = running({ status: 'completed', finishedAt: 5 }); + const s = applyAgentEvent(finished, { kind: 'model_resolved', model: 'gpt-5.6-sol' }, 9); + expect(s.model).toBe('gpt-5.6-sol'); + expect(s.status).toBe('completed'); + expect(s.finishedAt).toBe(5); + }); + + it.each(['running', 'awaiting_input', 'interrupted', 'failed'] as const)( + 'leaves %s alone', + (status) => { + const s = applyAgentEvent(running({ status }), { kind: 'model_resolved', model: 'm' }, 1); + expect(s.status).toBe(status); + expect(s.model).toBe('m'); + }, + ); + + it('returns the same object when the model is already known (no needless repaint)', () => { + const known = running({ model: 'gpt-5.6-sol' }); + expect(applyAgentEvent(known, { kind: 'model_resolved', model: 'gpt-5.6-sol' }, 1)).toBe(known); + }); + + it('lets a later explicit choice win (last write, not first)', () => { + const s = fold(running(), [ + { kind: 'model_resolved', model: 'gpt-5.6-sol' }, + { kind: 'assistant_message', model: 'gpt-5.4-mini' }, + ]); + expect(s.model).toBe('gpt-5.4-mini'); + }); +}); + describe('applyAgentEvent / log attribution', () => { it('stamps the agent on log lines only when one is supplied', () => { const withAgent = fold(running(), [{ kind: 'assistant_text', text: 'hi' }], 1, 'codex'); diff --git a/src/core/agent-events.ts b/src/core/agent-events.ts index f862058..77466ae 100644 --- a/src/core/agent-events.ts +++ b/src/core/agent-events.ts @@ -57,6 +57,16 @@ export type AgentEvent = * `assistant_text` / `tool_use` が運ぶ。 */ | { kind: 'assistant_message'; model?: string } + /** + * 解決済みモデルが**あとから**分かった。ストリームがモデル名を運ばない provider + * (Codex)が、別経路で調べた結果を報告するための専用イベント。 + * + * `session_started` / `assistant_message` にも `model` は載るが、あちらは + * 「ターンが動いている」ことを表す区切りでもあるため `status` を `running` に + * 戻してしまう。到着順が読めない非同期の問い合わせ結果をあれに相乗りさせると、 + * 完了したセッションが `running` に巻き戻る。こちらは**モデル欄だけ**を触る。 + */ + | { kind: 'model_resolved'; model: string } | { kind: 'assistant_text'; text: string; timestamp?: number } | { kind: 'tool_use'; @@ -270,6 +280,10 @@ export function applyAgentEvent( }; } + // 状態は動かさない(順序に依存しないので、ターンが終わったあとに届いても安全)。 + case 'model_resolved': + return state.model === event.model ? state : { ...state, model: event.model }; + case 'assistant_message': { const model = event.model ?? state.model; const status = state.pendingPermission ? state.status : 'running'; diff --git a/src/core/codex-adapter.spec.ts b/src/core/codex-adapter.spec.ts index dc25e76..9f83dab 100644 --- a/src/core/codex-adapter.spec.ts +++ b/src/core/codex-adapter.spec.ts @@ -381,6 +381,145 @@ describe('createCodexAdapter setModel', () => { }); }); +describe('createCodexAdapter resolveModel', () => { + // `codex exec --json` はモデル名をひとことも運ばない(実測 0.147.0)。`--model` を + // 明示していないセッションのモデル欄は、この問い合わせでしか埋まらない。 + it('reports the resolved model for a session running on the CLI default', async () => { + const codex = makeFakeCodex(); + const seen: string[] = []; + // `turn_context` はターン開始時に書かれるので、短いターンだと解決がストリームの + // 終わりに間に合わない。その**遅れて届く**経路をここで固定する(間に合わなければ + // 1 ターンで idle になったセッションのモデル欄が次の指示まで空のままになる)。 + let answer = (_model: string) => {}; + const pending = new Promise((resolve) => { + answer = resolve; + }); + const adapter = createCodexAdapter({ + spawn: codex.spawn, + resolveModel: (threadId) => { + seen.push(threadId); + return pending; + }, + }); + const { prompts, events, done } = drive(adapter); + + prompts.push('go'); + await waitFor(() => codex.requests.length === 1, 'the spawn'); + // 明示していないので `--model` は渡らない(CLI の既定に任せる)。 + expect(codex.requests[0]?.model).toBeUndefined(); + codex.at(0).emit(threadStarted('th-1')); + codex.at(0).emit(turnCompleted); + codex.at(0).end(); + await waitFor(() => events.some((e) => e.kind === 'turn_completed'), 'the turn to end'); + + answer('gpt-5.6-sol'); + await waitFor(() => events.some((e) => e.kind === 'model_resolved'), 'the resolved model'); + expect(seen).toEqual(['th-1']); + expect(events.find((e) => e.kind === 'model_resolved')).toEqual({ + kind: 'model_resolved', + model: 'gpt-5.6-sol', + }); + // 終端イベントの**あと**に流れる(`model_resolved` は status を触らないので、 + // 完了したセッションを `running` に巻き戻さない)。 + const kinds = events.map((e) => e.kind); + expect(kinds.indexOf('model_resolved')).toBeGreaterThan(kinds.indexOf('turn_completed')); + + prompts.close(); + await done; + }); + + it('does not ask when the model was chosen explicitly (Session already showed it)', async () => { + const codex = makeFakeCodex(); + let asked = 0; + const adapter = createCodexAdapter({ + spawn: codex.spawn, + resolveModel: async () => { + asked += 1; + return 'gpt-5.6-sol'; + }, + }); + const { prompts, events, done } = drive(adapter, { options: { model: 'gpt-5.4-mini' } }); + + prompts.push('go'); + await waitFor(() => codex.requests.length === 1, 'the spawn'); + codex.at(0).emit(threadStarted('th-1')); + codex.at(0).emit(turnCompleted); + codex.at(0).end(); + await waitFor(() => events.some((e) => e.kind === 'turn_completed'), 'the turn to end'); + + expect(asked).toBe(0); + expect(events.some((e) => e.kind === 'model_resolved')).toBe(false); + + prompts.close(); + await done; + }); + + it('asks once per thread, and again after /model resets to the CLI default', async () => { + const codex = makeFakeCodex(); + const asked: string[] = []; + const adapter = createCodexAdapter({ + spawn: codex.spawn, + resolveModel: async (threadId) => { + asked.push(threadId); + return 'gpt-5.6-sol'; + }, + }); + const { run, prompts, events, done } = drive(adapter); + + prompts.push('first'); + await waitFor(() => codex.requests.length === 1, 'the first spawn'); + codex.at(0).emit(threadStarted('th-1')); + codex.at(0).emit(turnCompleted); + codex.at(0).end(); + await waitFor(() => events.some((e) => e.kind === 'model_resolved'), 'the first answer'); + + // 同じスレッドを resume する 2 ターン目では問い合わせ直さない。 + prompts.push('second'); + await waitFor(() => codex.requests.length === 2, 'the second spawn'); + codex.at(1).emit(threadStarted('th-1')); + codex.at(1).emit(turnCompleted); + codex.at(1).end(); + await waitFor(() => events.filter((e) => e.kind === 'turn_completed').length === 2, 'turn 2'); + expect(asked).toEqual(['th-1']); + + // 明示指定 → 既定へ戻す、と往復したら引き直す(前の答えは別モデルのものかもしれない)。 + await run.setModel?.('gpt-5.4-mini'); + await run.setModel?.(undefined); + prompts.push('third'); + await waitFor(() => codex.requests.length === 3, 'the third spawn'); + codex.at(2).emit(threadStarted('th-1')); + codex.at(2).emit(turnCompleted); + codex.at(2).end(); + await waitFor(() => asked.length === 2, 'the re-ask'); + + prompts.close(); + await done; + }); + + it('survives a failed lookup (the model column just stays empty)', async () => { + const codex = makeFakeCodex(); + const adapter = createCodexAdapter({ + spawn: codex.spawn, + resolveModel: async () => { + throw new Error('no rollout directory'); + }, + }); + const { prompts, events, done } = drive(adapter); + + prompts.push('go'); + await waitFor(() => codex.requests.length === 1, 'the spawn'); + codex.at(0).emit(threadStarted('th-1')); + codex.at(0).emit(turnCompleted); + codex.at(0).end(); + await waitFor(() => events.some((e) => e.kind === 'turn_completed'), 'the turn to end'); + + expect(events.some((e) => e.kind === 'model_resolved')).toBe(false); + + prompts.close(); + await done; + }); +}); + describe('createCodexAdapter stream mapping', () => { it('drops junk lines and maps the rest through parseCodexEvent', async () => { const codex = makeFakeCodex(); diff --git a/src/core/codex-adapter.ts b/src/core/codex-adapter.ts index cbf3df3..78cf693 100644 --- a/src/core/codex-adapter.ts +++ b/src/core/codex-adapter.ts @@ -94,6 +94,13 @@ export function createCodexAdapter(deps: { generateTitle?: (prompt: string) => Promise; /** 導入・ログイン検出(I/O は `utils/codex.ts` の `detectCodexAvailability`)。 */ checkAvailability?: () => Promise; + /** + * そのスレッドが実際に使っているモデルを調べる(I/O は `utils/codex.ts` の + * `resolveCodexRolloutModel`)。`codex exec --json` はモデル名を運ばないので、 + * `--model` を明示していないセッションはこれが唯一の出所になる。 + * 省略可(渡さなければモデル欄は空のまま = 従来どおり)。 + */ + resolveModel?: (threadId: string) => Promise; /** TUI 内ログインのプロセス起動(I/O は `utils/agent-login.ts` の `spawnLogin`)。 */ spawnLogin?: (command: string, args: readonly string[]) => AgentLoginProcess; }): AgentAdapter { @@ -123,6 +130,34 @@ export function createCodexAdapter(deps: { let interrupted = false; let current: CodexProcess | undefined; + // 解決済みモデルの遅延問い合わせ。`codex exec --json` はモデル名を運ばないので、 + // `--model` を明示していないセッション(= CLI の既定に任せている)のモデル欄は + // これでしか埋まらない。**ストリームは止めない** — 問い合わせは走らせておいて、 + // answered ぶんを後続イベントの合間に流す。 + let modelProbe: Promise | undefined; + let pendingModel: string | undefined; + let probedThread: string | undefined; + + const probeModel = (id: string): void => { + const resolve = deps.resolveModel; + // 明示指定があるときは Session が先に表示済みなので問い合わせない。 + if (!resolve || model !== undefined || probedThread === id || modelProbe) { + return; + } + probedThread = id; + modelProbe = resolve(id) + .then((found) => { + // 待っている間に `/model` で明示選択されたら、そちらが正しい。 + if (found !== undefined && model === undefined) { + pendingModel = found; + } + }) + .catch(() => undefined) + .finally(() => { + modelProbe = undefined; + }); + }; + const abort = () => { current?.kill(); }; @@ -167,9 +202,15 @@ export function createCodexAdapter(deps: { } if (event.type === 'thread.started') { threadId = event.thread_id; + probeModel(event.thread_id); } else if (event.type === 'turn.completed' || event.type === 'turn.failed') { sawTerminal = true; } + // 解決済みモデルが届いていれば先に流す(長いターンでも一覧が早く埋まる)。 + if (pendingModel !== undefined) { + yield { kind: 'model_resolved', model: pendingModel }; + pendingModel = undefined; + } yield* parseCodexEvent(event); } } finally { @@ -181,17 +222,27 @@ export function createCodexAdapter(deps: { proc.kill(); } - if (interrupted || request.abortController.signal.aborted) { - // 中断は Session 側で既に `interrupted` を確定させてある。 - continue; - } - if (!sawTerminal) { + // 中断は Session 側で既に `interrupted` を確定させてあるので何も出さない。 + if (!interrupted && !request.abortController.signal.aborted && !sawTerminal) { const { code, stderr } = proc.result(); const detail = stderr.trim() || `codex exited with code ${code ?? 'null'}`; yield code === 0 ? { kind: 'turn_completed', text: '' } : { kind: 'turn_stopped', cause: classifyCodexError(detail), detail }; } + + // 問い合わせの回収は**終端イベントを流したあと**。`turn_context` は + // ターン開始時に書かれるので、短いターンだと解決がストリームの終わりに + // 間に合わない。ここで待たないと、1 ターンで終わって idle になった + // セッションのモデル欄が次の指示まで空のままになる。`model_resolved` は + // status を触らないので、完了イベントの後に流しても巻き戻さない。 + if (modelProbe && !request.abortController.signal.aborted) { + await modelProbe; + } + if (pendingModel !== undefined) { + yield { kind: 'model_resolved', model: pendingModel }; + pendingModel = undefined; + } } } finally { request.abortController.signal.removeEventListener('abort', abort); @@ -207,6 +258,11 @@ export function createCodexAdapter(deps: { // 走っているターンには反映されない(`setModel` の契約としては許容範囲)。 setModel: (next) => { model = next; + // 「既定に戻す」を選んだら、その CLI 既定が何なのかを次のターンで引き直す + // (前回の解決結果は、明示指定していた別モデルのものかもしれない)。 + if (next === undefined) { + probedThread = undefined; + } }, }; }, diff --git a/src/core/codex-rollout.spec.ts b/src/core/codex-rollout.spec.ts new file mode 100644 index 0000000..0fd448f --- /dev/null +++ b/src/core/codex-rollout.spec.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { + codexRolloutModel, + codexRolloutModelFromText, + isCodexRolloutFile, +} from '@/core/codex-rollout'; + +const THREAD = '019ff155-b5c3-7380-87f1-02c13d2a66d4'; + +describe('isCodexRolloutFile', () => { + it.each([ + // 実測のファイル名(codex-cli 0.147.0)。 + [`rollout-2026-08-11T23-59-13-${THREAD}.jsonl`, THREAD, true], + // 別スレッドのファイル。 + ['rollout-2026-08-11T23-57-20-019ff153-f9a0-7083-8a9f-d909f5d73d2a.jsonl', THREAD, false], + // id が接尾辞になっているだけ(区切りの `-` が無い)ものは拾わない。 + [`rollout-2026-08-11T23-59-13-x${THREAD}.jsonl`, THREAD, false], + // rollout 以外のファイル。 + [`${THREAD}.jsonl`, THREAD, false], + [`rollout-2026-08-11T23-59-13-${THREAD}.json`, THREAD, false], + // 空の thread id で全部にマッチさせない。 + [`rollout-2026-08-11T23-59-13-${THREAD}.jsonl`, '', false], + ])('%s for %s → %s', (name, threadId, expected) => { + expect(isCodexRolloutFile(name, threadId)).toBe(expected); + }); +}); + +describe('codexRolloutModel', () => { + it('reads turn_context.model (the only place the resolved model appears)', () => { + expect( + codexRolloutModel({ + type: 'turn_context', + payload: { turn_id: 't1', cwd: '/tmp/wt', model: 'gpt-5.6-sol' }, + }), + ).toBe('gpt-5.6-sol'); + }); + + it.each([ + [ + 'session_meta は model_provider しか持たない', + { type: 'session_meta', payload: { model_provider: 'openai' } }, + ], + ['他の行', { type: 'response_item', payload: { model: 'gpt-5.6-sol' } }], + ['payload なし', { type: 'turn_context' }], + ['model が文字列でない', { type: 'turn_context', payload: { model: 42 } }], + ['model が空', { type: 'turn_context', payload: { model: ' ' } }], + ['オブジェクトでない', 'turn_context'], + ['null', null], + ])('%s → undefined', (_label, value) => { + expect(codexRolloutModel(value)).toBeUndefined(); + }); +}); + +describe('codexRolloutModelFromText', () => { + it('finds the model in a rollout head (session_meta first, turn_context after)', () => { + const text = [ + JSON.stringify({ type: 'session_meta', payload: { model_provider: 'openai' } }), + JSON.stringify({ type: 'event_msg', payload: { model_context_window: 258400 } }), + JSON.stringify({ type: 'world_state', payload: {} }), + JSON.stringify({ type: 'turn_context', payload: { model: 'gpt-5.6-sol' } }), + '', + ].join('\n'); + expect(codexRolloutModelFromText(text)).toBe('gpt-5.6-sol'); + }); + + it('prefers the last turn_context (resume appends further turns)', () => { + const text = [ + JSON.stringify({ type: 'turn_context', payload: { model: 'gpt-5.4-mini' } }), + JSON.stringify({ type: 'turn_context', payload: { model: 'gpt-5.6-sol' } }), + ].join('\n'); + expect(codexRolloutModelFromText(text)).toBe('gpt-5.6-sol'); + }); + + it('ignores a final line truncated by the read cap', () => { + const text = [ + JSON.stringify({ type: 'turn_context', payload: { model: 'gpt-5.6-sol' } }), + '{"type":"response_item","payload":{"content":"cut off here', + ].join('\n'); + expect(codexRolloutModelFromText(text)).toBe('gpt-5.6-sol'); + }); + + it('returns undefined when no turn_context was read', () => { + const text = JSON.stringify({ type: 'session_meta', payload: { model_provider: 'openai' } }); + expect(codexRolloutModelFromText(text)).toBeUndefined(); + expect(codexRolloutModelFromText('')).toBeUndefined(); + }); +}); diff --git a/src/core/codex-rollout.ts b/src/core/codex-rollout.ts new file mode 100644 index 0000000..497e25e --- /dev/null +++ b/src/core/codex-rollout.ts @@ -0,0 +1,86 @@ +/** + * Codex の rollout ファイル(`$CODEX_HOME/sessions/<年>/<月>/<日>/rollout-<時刻>-.jsonl`) + * から**実際に動いたモデル**を読み取るための純関数。読み出しの I/O は + * `utils/codex.ts` の `resolveCodexRolloutModel`。 + * + * なぜここまでするのか: `codex exec --json` の stdout は**モデル名をひとことも運ばない** + * (実測 codex-cli 0.147.0 — `thread.started` / `turn.started` / `turn.completed` の + * どれにも無い。`__fixtures__/codex-basic.jsonl` を参照)。Claude の `system/init` に + * 当たるものが無いので、`--model` を明示していないセッションは解決済みモデルが + * 分からず、一覧のモデル欄が空のままだった。 + * + * `codex debug models` にも既定を示す印は無く、`codex doctor --json` は + * `model = ""` としか答えない。**唯一 CLI が解決結果を書き残すのが + * rollout の `turn_context`** で、`-m` を渡さない実行でも解決済みの slug + * (実測 `gpt-5.6-sol`)が入る。だからここを読む。 + * + * カタログの先頭(priority 1)を既定とみなす手もあるが、`~/.codex/config.toml` で + * `model` を設定しているユーザーに**嘘のモデル名**を出すことになるので採らない。 + */ + +/** rollout の 1 行(読む項目だけ。増えても落ちない)。 */ +interface CodexRolloutLine { + type?: unknown; + payload?: unknown; +} + +/** + * ファイル名がこのスレッドの rollout か。 + * + * 名前は `rollout--.jsonl` で、`thread_id` は + * `thread.started` が運ぶ UUID そのもの。時刻部分は**ローカル時刻**なので当てにせず、 + * 末尾の id だけで突き合わせる。 + */ +export function isCodexRolloutFile(fileName: string, threadId: string): boolean { + return ( + threadId.length > 0 && + fileName.startsWith('rollout-') && + fileName.endsWith(`-${threadId}.jsonl`) + ); +} + +/** + * rollout の 1 行から `turn_context.model` を取り出す(それ以外の行は undefined)。 + * + * `session_meta` は `model_provider`(`"openai"`)しか持たず**モデル slug は無い**ので、 + * ターンごとに書かれる `turn_context` が唯一の出所になる。 + */ +export function codexRolloutModel(value: unknown): string | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const line = value as CodexRolloutLine; + if (line.type !== 'turn_context' || !line.payload || typeof line.payload !== 'object') { + return undefined; + } + const model = (line.payload as { model?: unknown }).model; + return typeof model === 'string' && model.trim().length > 0 ? model : undefined; +} + +/** + * rollout の先頭部分(JSONL のテキスト)から解決済みモデルを拾う。 + * + * **最後に見つかったものを採る**。`codex exec resume` は同じファイルへ追記していく + * ので、途中でモデルを変えていれば後の `turn_context` のほうが今の状態に近い。 + * + * 壊れた行・途中で切れた最終行は黙って捨てる(ここは best-effort の表示用で、 + * 読めなければモデル欄が空のままになるだけ)。 + */ +export function codexRolloutModelFromText(text: string): string | undefined { + let found: string | undefined; + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + continue; + } + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + // 読み込み上限で切れた最終行など。次の行へ進む。 + continue; + } + found = codexRolloutModel(parsed) ?? found; + } + return found; +} diff --git a/src/core/index.ts b/src/core/index.ts index 78b17d4..f088bdd 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -17,6 +17,7 @@ export * from './codex-errors'; export * from './codex-events'; export * from './codex-models'; export * from './codex-parse'; +export * from './codex-rollout'; export * from './commands'; export * from './composer-layout'; export * from './config'; diff --git a/src/utils/codex.spec.ts b/src/utils/codex.spec.ts index 092cf5e..d1dfc7a 100644 --- a/src/utils/codex.spec.ts +++ b/src/utils/codex.spec.ts @@ -1,6 +1,14 @@ -import { describe, expect, it } from 'vitest'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { CodexSpawnRequest } from '@/core'; -import { codexArgs, detectCodexAvailability, spawnCodex } from '@/utils/codex'; +import { + codexArgs, + detectCodexAvailability, + resolveCodexRolloutModel, + spawnCodex, +} from '@/utils/codex'; /** * `codex exec` の引数組み立てだけを見る純粋なテスト。**実プロセスは起動しない** @@ -189,3 +197,69 @@ describe('detectCodexAvailability', () => { expect(a).toEqual({ installed: false, loggedIn: false }); }); }); + +/** + * rollout からの解決済みモデル読み出し。実 `codex` は要らない(ファイル配置と + * 内容だけが問題)ので、`mkdtemp` で `$CODEX_HOME` を丸ごと組み立てて検証する。 + * レイアウト・行の形は実測(codex-cli 0.147.0)に合わせてある。 + */ +describe('resolveCodexRolloutModel', () => { + const THREAD = '019ff155-b5c3-7380-87f1-02c13d2a66d4'; + + /** `/sessions/<年>/<月>/<日>/rollout-<時刻>-.jsonl` を 1 本作る。 */ + async function writeRollout( + home: string, + day: readonly [string, string, string], + threadId: string, + lines: readonly unknown[], + ): Promise { + const dir = join(home, 'sessions', ...day); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, `rollout-${day.join('-')}T23-59-13-${threadId}.jsonl`), + `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`, + 'utf8', + ); + } + + const meta = { type: 'session_meta', payload: { model_provider: 'openai' } }; + const turn = (model: string) => ({ type: 'turn_context', payload: { model } }); + + let home: string; + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'codiva-codex-home-')); + }); + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + }); + + const fast = { retries: 1, retryMs: 1 }; + + it('reads the model the CLI actually resolved for that thread', async () => { + await writeRollout(home, ['2026', '08', '11'], THREAD, [meta, turn('gpt-5.6-sol')]); + expect(await resolveCodexRolloutModel(THREAD, { home, ...fast })).toBe('gpt-5.6-sol'); + }); + + it('picks the rollout of the right thread, not just the newest one', async () => { + await writeRollout(home, ['2026', '08', '11'], THREAD, [meta, turn('gpt-5.4-mini')]); + await writeRollout(home, ['2026', '08', '12'], 'other-thread-id', [meta, turn('gpt-5.6-sol')]); + expect(await resolveCodexRolloutModel(THREAD, { home, ...fast })).toBe('gpt-5.4-mini'); + }); + + it('gives up quietly when the thread has no rollout yet (model column stays empty)', async () => { + await writeRollout(home, ['2026', '08', '11'], 'someone-else', [meta, turn('gpt-5.6-sol')]); + expect(await resolveCodexRolloutModel(THREAD, { home, ...fast })).toBeUndefined(); + }); + + it('gives up quietly when CODEX_HOME does not exist at all', async () => { + const missing = join(home, 'nope'); + expect(await resolveCodexRolloutModel(THREAD, { home: missing, ...fast })).toBeUndefined(); + }); + + it('retries until the turn_context has been written (it lands after thread.started)', async () => { + await writeRollout(home, ['2026', '08', '11'], THREAD, [meta]); + const pending = resolveCodexRolloutModel(THREAD, { home, retries: 40, retryMs: 5 }); + await writeRollout(home, ['2026', '08', '11'], THREAD, [meta, turn('gpt-5.6-sol')]); + expect(await pending).toBe('gpt-5.6-sol'); + }); +}); diff --git a/src/utils/codex.ts b/src/utils/codex.ts index e7e3051..f5fa77c 100644 --- a/src/utils/codex.ts +++ b/src/utils/codex.ts @@ -1,10 +1,15 @@ import { execFile, spawn } from 'node:child_process'; +import { open, readdir } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; import { promisify } from 'node:util'; import { type AgentAvailability, type CodexProcess, type CodexSpawnRequest, + codexRolloutModelFromText, createJsonlSplitter, + isCodexRolloutFile, type ModelOption, toCodexModelOptions, } from '@/core'; @@ -199,6 +204,123 @@ export async function fetchCodexModelCatalog(opts?: { } } +/** + * rollout から読む先頭バイト数。`session_meta` が `base_instructions`(モデルの + * システムプロンプト全文)を 1 行で運ぶので実測で数十 KB あり、その次あたりに + * `turn_context` が来る。ファイル全体は平均 1MB 超なので**先頭だけ**読む。 + */ +const ROLLOUT_HEAD_BYTES = 512 * 1024; + +/** 何日ぶんの日付ディレクトリまで遡って探すか(当日に無ければ日付跨ぎを疑う程度)。 */ +const ROLLOUT_MAX_DAY_DIRS = 8; + +/** `turn_context` が書かれるまで待つリトライ(間隔 × 回数)。 */ +const ROLLOUT_RETRY_MS = 200; +const ROLLOUT_RETRIES = 6; + +/** `$CODEX_HOME`(未設定なら `~/.codex`)。 */ +function codexHome(): string { + const home = process.env.CODEX_HOME?.trim(); + return home && home.length > 0 ? home : join(homedir(), '.codex'); +} + +/** 名前でソートした部分ディレクトリを新しい順に返す(`sessions/<年>/<月>/<日>`)。 */ +async function subdirsDesc(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); + return entries + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort() + .reverse(); +} + +/** `sessions/` 以下の日付ディレクトリを新しい順に列挙する(上限付き)。 */ +async function rolloutDayDirs(sessionsDir: string, limit: number): Promise { + const days: string[] = []; + for (const year of await subdirsDesc(sessionsDir)) { + const yearDir = join(sessionsDir, year); + for (const month of await subdirsDesc(yearDir)) { + const monthDir = join(yearDir, month); + for (const day of await subdirsDesc(monthDir)) { + days.push(join(monthDir, day)); + if (days.length >= limit) { + return days; + } + } + } + } + return days; +} + +/** そのスレッドの rollout ファイルを探す(新しい日付から。見つからなければ undefined)。 */ +async function findRollout(home: string, threadId: string): Promise { + const sessionsDir = join(home, 'sessions'); + for (const dayDir of await rolloutDayDirs(sessionsDir, ROLLOUT_MAX_DAY_DIRS)) { + const names = await readdir(dayDir).catch(() => []); + const hit = names.find((name) => isCodexRolloutFile(name, threadId)); + if (hit) { + return join(dayDir, hit); + } + } + return undefined; +} + +/** ファイルの先頭 `ROLLOUT_HEAD_BYTES` を読む(無ければ undefined)。 */ +async function readHead(path: string): Promise { + const handle = await open(path, 'r').catch(() => undefined); + if (!handle) { + return undefined; + } + try { + const buffer = Buffer.allocUnsafe(ROLLOUT_HEAD_BYTES); + const { bytesRead } = await handle.read(buffer, 0, ROLLOUT_HEAD_BYTES, 0); + return buffer.subarray(0, bytesRead).toString('utf8'); + } catch { + return undefined; + } finally { + await handle.close().catch(() => undefined); + } +} + +/** リトライの待ち。**TUI の終了を引き止めない**ようタイマーは unref する。 */ +const sleep = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms).unref?.(); + }); + +/** + * そのスレッドが**実際に使っているモデル**を rollout から読む + * (`core/codex-rollout.ts` の理由書きを参照)。 + * + * `turn_context` はターンが始まった時点で書かれるので、`thread.started` の直後だと + * まだ無いことがある。数回だけ間を置いて読み直し、それでも読めなければ諦める + * (**throw しない** — モデル欄が空のままになるだけで、セッションは壊さない)。 + */ +export async function resolveCodexRolloutModel( + threadId: string, + opts?: { home?: string; retries?: number; retryMs?: number }, +): Promise { + const home = opts?.home ?? codexHome(); + const retries = opts?.retries ?? ROLLOUT_RETRIES; + const retryMs = opts?.retryMs ?? ROLLOUT_RETRY_MS; + for (let attempt = 0; attempt <= retries; attempt += 1) { + if (attempt > 0) { + await sleep(retryMs); + } + try { + const path = await findRollout(home, threadId); + const text = path ? await readHead(path) : undefined; + const model = text ? codexRolloutModelFromText(text) : undefined; + if (model) { + return model; + } + } catch { + // 権限・レイアウト変更など。次の試行へ(最後まで駄目なら undefined)。 + } + } + return undefined; +} + /** 導入・ログイン確認の上限(サブプロセスが固まっても TUI を止めない)。 */ const PROBE_TIMEOUT_MS = 4000; From 84e5ed94de6e400f9fa63c8be7f04cdf64f84be7 Mon Sep 17 00:00:00 2001 From: takecchi Date: Wed, 12 Aug 2026 00:32:29 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E8=A7=A3=E6=B1=BA=E6=B8=88=E3=81=BF?= =?UTF-8?q?=E3=83=A2=E3=83=87=E3=83=AB=E3=81=AE=E9=81=85=E3=82=8C=E3=81=9F?= =?UTF-8?q?=E7=AD=94=E3=81=88=E3=81=8C=E7=8A=B6=E6=85=8B=E3=82=92=E6=B1=9A?= =?UTF-8?q?=E3=81=99=E3=81=AE=E3=82=92=E9=98=B2=E3=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit セルフレビューで見つかった 3 点。いずれも「問い合わせの答えが遅れて届く」ことに 起因していて、放置すると**間違ったモデル名が一覧に出たまま state.json に焼き付く**。 1. 明示選択を上書きしていた: 「明示指定が勝つ」判定が問い合わせの `.then` の中だけに あり、保留したあとに `/model` で選び直されても捨てていなかった。Codex は二度と モデルを報告しないので、一度ずれると永久にずれたままになる。yield する直前に 判定し直す(`takeResolvedModel`)。 2. 中断・エージェント切替の後にも流していた: 終端イベント側にはある `interrupted` の ガードが無く、`/agent` で Claude へ切り替えた直後に遅れて届いた Codex の slug が Claude のセッションに出てしまう(`agent_switched` が model をクリアした直後に上書き されるため)。`interrupted` / `aborted` のときは捨て、待ちもしない。 3. 空振りを記憶して二度と引き直さなかった: `turn_context` はターン開始時に書かれる ので初回が早すぎると空振りするが、`probedThread` を先に latch していたため、次の ターンなら即座に読めるのに諦めたままになっていた。空振りは記憶せず、代わりに 試行回数の上限(3 回)で「そもそも取れない環境」を打ち切る。 `codexRolloutModelFromText` のコメントも修正(「最後の turn_context が勝つ」のは 呼び出し側が渡す**先頭 512KB の範囲**での話。モデルが途中で変わるのは明示選択した ときだけで、そのときはそもそも問い合わせない)。 4 件とも修正前のコードで落ちることを確認した回帰テスト付き。 --- src/core/codex-adapter.spec.ts | 119 +++++++++++++++++++++++++++++++++ src/core/codex-adapter.ts | 66 +++++++++++++++--- src/core/codex-rollout.ts | 13 ++-- 3 files changed, 183 insertions(+), 15 deletions(-) diff --git a/src/core/codex-adapter.spec.ts b/src/core/codex-adapter.spec.ts index 9f83dab..e2e22b3 100644 --- a/src/core/codex-adapter.spec.ts +++ b/src/core/codex-adapter.spec.ts @@ -496,6 +496,125 @@ describe('createCodexAdapter resolveModel', () => { await done; }); + // 問い合わせ中に `/model` で選び直されたら、届いた答えはもう古い。捨てないと + // 「次のターンは選んだモデルで動くのに、一覧には既定のモデル名が出たまま」になり、 + // Codex は二度とモデルを報告しないので**永久にずれたまま** state.json にも焼き付く。 + it('drops the answer when /model overtook it while the lookup was in flight', async () => { + const codex = makeFakeCodex(); + let answer = (_model: string) => {}; + const pending = new Promise((resolve) => { + answer = resolve; + }); + const adapter = createCodexAdapter({ spawn: codex.spawn, resolveModel: () => pending }); + const { run, prompts, events, done } = drive(adapter); + + prompts.push('go'); + await waitFor(() => codex.requests.length === 1, 'the spawn'); + codex.at(0).emit(threadStarted('th-1')); + // **答えが先に届き**(ここで保留される)、そのあとユーザーが明示選択する、という + // 順序でないと再現しない(この順序を崩すと問い合わせ側のガードだけで素通りする)。 + answer('gpt-5.6-sol'); + for (let i = 0; i < 5; i += 1) { + await tick(); + } + await run.setModel?.('gpt-5.4-mini'); + codex.at(0).emit(turnCompleted); + codex.at(0).end(); + await waitFor(() => events.some((e) => e.kind === 'turn_completed'), 'the turn to end'); + + expect(events.some((e) => e.kind === 'model_resolved')).toBe(false); + + prompts.close(); + await done; + }); + + // `/agent` で切り替えると `run.interrupt()` が呼ばれ、`agent_switched` が model を + // クリアする。遅れて届いた Codex の slug をそこへ流すと、Claude のセッションに + // Codex のモデル名が出たまま永続化される。 + it('drops the answer when the run was interrupted (agent switch / Ctrl+C)', async () => { + const codex = makeFakeCodex(); + let answer = (_model: string) => {}; + const pending = new Promise((resolve) => { + answer = resolve; + }); + const adapter = createCodexAdapter({ spawn: codex.spawn, resolveModel: () => pending }); + const { run, prompts, events, done } = drive(adapter); + + prompts.push('go'); + await waitFor(() => codex.requests.length === 1, 'the spawn'); + codex.at(0).emit(threadStarted('th-1')); + await run.interrupt?.(); + answer('gpt-5.6-sol'); + codex.at(0).end(); + await waitFor(() => codex.at(0).wasKilled(), 'the process to be killed'); + prompts.close(); + await done; + + expect(events.some((e) => e.kind === 'model_resolved')).toBe(false); + }); + + // 空振り(`turn_context` がまだ書かれていない)を記憶してしまうと、次のターンなら + // すぐ読めるのに二度と引き直さないセッションになる。 + it('retries on the next turn when the rollout was not readable yet', async () => { + const codex = makeFakeCodex(); + const answers: (string | undefined)[] = [undefined, 'gpt-5.6-sol']; + let asked = 0; + const adapter = createCodexAdapter({ + spawn: codex.spawn, + resolveModel: async () => answers[asked++], + }); + const { prompts, events, done } = drive(adapter); + + prompts.push('first'); + await waitFor(() => codex.requests.length === 1, 'the first spawn'); + codex.at(0).emit(threadStarted('th-1')); + codex.at(0).emit(turnCompleted); + codex.at(0).end(); + await waitFor(() => events.some((e) => e.kind === 'turn_completed'), 'the first turn'); + expect(events.some((e) => e.kind === 'model_resolved')).toBe(false); + + prompts.push('second'); + await waitFor(() => codex.requests.length === 2, 'the second spawn'); + codex.at(1).emit(threadStarted('th-1')); + codex.at(1).emit(turnCompleted); + codex.at(1).end(); + await waitFor(() => events.some((e) => e.kind === 'model_resolved'), 'the retry to answer'); + expect(asked).toBe(2); + + prompts.close(); + await done; + }); + + // ただし、そもそも取れない環境で毎ターン探し回らない。 + it('stops asking after a few misses (never-readable rollout)', async () => { + const codex = makeFakeCodex(); + let asked = 0; + const adapter = createCodexAdapter({ + spawn: codex.spawn, + resolveModel: async () => { + asked += 1; + return undefined; + }, + }); + const { prompts, events, done } = drive(adapter); + + for (let turn = 0; turn < 5; turn += 1) { + prompts.push(`turn ${turn}`); + await waitFor(() => codex.requests.length === turn + 1, `spawn ${turn}`); + codex.at(turn).emit(threadStarted('th-1')); + codex.at(turn).emit(turnCompleted); + codex.at(turn).end(); + await waitFor( + () => events.filter((e) => e.kind === 'turn_completed').length === turn + 1, + `turn ${turn} to end`, + ); + } + expect(asked).toBe(3); + + prompts.close(); + await done; + }); + it('survives a failed lookup (the model column just stays empty)', async () => { const codex = makeFakeCodex(); const adapter = createCodexAdapter({ diff --git a/src/core/codex-adapter.ts b/src/core/codex-adapter.ts index 78cf693..b034da3 100644 --- a/src/core/codex-adapter.ts +++ b/src/core/codex-adapter.ts @@ -27,6 +27,13 @@ import type { CodexSandbox, EffortLevel } from './config'; * したがって `capabilities.permissions` は false で、`requestPermission` は呼ばれない。 */ +/** + * 解決済みモデルの問い合わせを 1 セッションで何回まで試すか。空振り(rollout が + * まだ書かれていない)は次のターンで引き直すが、そもそも取れない環境 + * (rollout を書かない設定・別レイアウト)で毎ターン探し回らないための上限。 + */ +const MAX_MODEL_PROBES = 3; + /** 1 ターンぶんの `codex exec` を起動するための入力(I/O 実装は `utils/codex.ts`)。 */ export interface CodexSpawnRequest { /** セッションの worktree。 */ @@ -137,6 +144,7 @@ export function createCodexAdapter(deps: { let modelProbe: Promise | undefined; let pendingModel: string | undefined; let probedThread: string | undefined; + let probeAttempts = 0; const probeModel = (id: string): void => { const resolve = deps.resolveModel; @@ -144,20 +152,52 @@ export function createCodexAdapter(deps: { if (!resolve || model !== undefined || probedThread === id || modelProbe) { return; } + // 取れない環境(rollout を書かない設定・別レイアウト)で毎ターン探し回らない。 + if (probeAttempts >= MAX_MODEL_PROBES) { + return; + } + probeAttempts += 1; probedThread = id; + // **空振りは記憶しない**。`turn_context` はターン開始時に書かれるので、初回が + // 早すぎて空振りすることがある。ここで id を latch したままにすると、次の + // ターンなら即座に読めるのに二度と引き直さないセッションになる。 + const retryLater = () => { + probedThread = undefined; + }; modelProbe = resolve(id) .then((found) => { - // 待っている間に `/model` で明示選択されたら、そちらが正しい。 - if (found !== undefined && model === undefined) { - pendingModel = found; + if (found === undefined) { + retryLater(); + return; } + pendingModel = found; }) - .catch(() => undefined) + .catch(retryLater) .finally(() => { modelProbe = undefined; }); }; + /** + * 解決済みモデルを 1 回だけ流す。**答えが古くなっていたら捨てる** — 問い合わせ中に + * `/model` で明示選択されたり(そちらが正しい)、中断・エージェント切替が起きたり + * (切替先は別 provider なので Codex の slug は嘘になる)した後に流すと、 + * 一覧に間違ったモデル名が出たまま `state.json` にも焼き付く。 + */ + const takeResolvedModel = (): AgentEvent | undefined => { + const found = pendingModel; + pendingModel = undefined; + if ( + found === undefined || + model !== undefined || + interrupted || + request.abortController.signal.aborted + ) { + return undefined; + } + return { kind: 'model_resolved', model: found }; + }; + const abort = () => { current?.kill(); }; @@ -207,9 +247,9 @@ export function createCodexAdapter(deps: { sawTerminal = true; } // 解決済みモデルが届いていれば先に流す(長いターンでも一覧が早く埋まる)。 - if (pendingModel !== undefined) { - yield { kind: 'model_resolved', model: pendingModel }; - pendingModel = undefined; + const resolvedMid = takeResolvedModel(); + if (resolvedMid) { + yield resolvedMid; } yield* parseCodexEvent(event); } @@ -236,12 +276,16 @@ export function createCodexAdapter(deps: { // 間に合わない。ここで待たないと、1 ターンで終わって idle になった // セッションのモデル欄が次の指示まで空のままになる。`model_resolved` は // status を触らないので、完了イベントの後に流しても巻き戻さない。 - if (modelProbe && !request.abortController.signal.aborted) { + // + // 中断・破棄のときは**待たない**(`Ctrl+C` の直後や終了処理を、表示用の + // 問い合わせで最大 1 秒引き止めない)。タイマーは unref してあるので、 + // 置き去りにした問い合わせが TUI の終了を止めることもない。 + if (modelProbe && !interrupted && !request.abortController.signal.aborted) { await modelProbe; } - if (pendingModel !== undefined) { - yield { kind: 'model_resolved', model: pendingModel }; - pendingModel = undefined; + const resolved = takeResolvedModel(); + if (resolved) { + yield resolved; } } } finally { diff --git a/src/core/codex-rollout.ts b/src/core/codex-rollout.ts index 497e25e..6938fe9 100644 --- a/src/core/codex-rollout.ts +++ b/src/core/codex-rollout.ts @@ -58,12 +58,17 @@ export function codexRolloutModel(value: unknown): string | undefined { } /** - * rollout の先頭部分(JSONL のテキスト)から解決済みモデルを拾う。 + * 渡されたテキスト(rollout の一部)から解決済みモデルを拾う。 * - * **最後に見つかったものを採る**。`codex exec resume` は同じファイルへ追記していく - * ので、途中でモデルを変えていれば後の `turn_context` のほうが今の状態に近い。 + * **渡された範囲で最後に見つかったものを採る**。`codex exec resume` は同じファイルへ + * 追記していくので、後の `turn_context` のほうが新しい。 * - * 壊れた行・途中で切れた最終行は黙って捨てる(ここは best-effort の表示用で、 + * ただし呼び出し側(`resolveCodexRolloutModel`)が渡すのは**ファイルの先頭だけ**なので、 + * 「セッション全体で最後」ではなく「読んだ範囲で最後」であることに注意。これで足りるのは、 + * 途中でモデルが変わるのは `/model` で明示選択したときだけで、**そのときはそもそも + * 問い合わせをしない**(明示指定の値を Session が直接表示する)ため。 + * + * 壊れた行・読み込み上限で切れた最終行は黙って捨てる(ここは best-effort の表示用で、 * 読めなければモデル欄が空のままになるだけ)。 */ export function codexRolloutModelFromText(text: string): string | undefined { From 6f4220268cf14d03c25952944f91b2de100aa54d Mon Sep 17 00:00:00 2001 From: takecchi Date: Wed, 12 Aug 2026 02:32:58 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E6=97=A2=E5=AE=9A=E3=81=B8=E6=88=BB?= =?UTF-8?q?=E3=81=97=E3=81=9F=E3=81=A8=E3=81=8D=E3=81=AB=E3=83=A2=E3=83=87?= =?UTF-8?q?=E3=83=AB=E3=81=AE=E6=8E=A2=E7=B4=A2=E4=BA=88=E7=AE=97=E3=82=82?= =?UTF-8?q?=E3=83=AA=E3=82=BB=E3=83=83=E3=83=88=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー指摘の修正。`setModel(undefined)` は `probedThread` だけをクリアしていて `probeAttempts` を戻していなかったため、探索上限(3 回)に達したあとは「既定に戻したら 次のターンで引き直す」が成立していなかった。 再現: rollout が一時的に読めず最初の 3 ターンが空振りしたセッションで、そのあと 「明示モデルを選択 → CLI の既定へ戻す」と操作しても `probeModel()` が `probeAttempts >= MAX_MODEL_PROBES` で即 return し、モデル欄は明示モデルの表示から 更新されないまま永続化される。 `MAX_MODEL_PROBES` は「そもそも rollout を読めない環境で毎ターン探し回らない」ための 上限であって、ユーザーが明示的に既定へ戻す操作まで縛るものではない。予算はユーザーの 操作回数で自然に頭打ちになるので、既定へ戻したときにリセットする。 回帰テスト付き(修正前のコードで落ちることを確認)。 --- src/core/codex-adapter.spec.ts | 50 ++++++++++++++++++++++++++++++++++ src/core/codex-adapter.ts | 7 +++++ 2 files changed, 57 insertions(+) diff --git a/src/core/codex-adapter.spec.ts b/src/core/codex-adapter.spec.ts index e2e22b3..869488a 100644 --- a/src/core/codex-adapter.spec.ts +++ b/src/core/codex-adapter.spec.ts @@ -615,6 +615,56 @@ describe('createCodexAdapter resolveModel', () => { await done; }); + // 上限は「読めない環境で毎ターン探し回らない」ためのもので、ユーザーが明示的に + // 既定へ戻す操作まで縛るためのものではない。予算を戻さないと、序盤に空振りして + // 使い切ったセッションは「明示モデル → 既定へ戻す」としてもモデル欄が明示モデルの + // まま二度と更新されない。 + it('restores the probe budget when /model goes back to the CLI default', async () => { + const codex = makeFakeCodex(); + let asked = 0; + const adapter = createCodexAdapter({ + spawn: codex.spawn, + resolveModel: async () => { + asked += 1; + // 最初の 3 回(= 上限ぶん)は空振り、そのあとは読める。 + return asked > 3 ? 'gpt-5.6-sol' : undefined; + }, + }); + const { run, prompts, events, done } = drive(adapter); + + const turn = async (index: number) => { + prompts.push(`turn ${index}`); + await waitFor(() => codex.requests.length === index + 1, `spawn ${index}`); + codex.at(index).emit(threadStarted('th-1')); + codex.at(index).emit(turnCompleted); + codex.at(index).end(); + await waitFor( + () => events.filter((e) => e.kind === 'turn_completed').length === index + 1, + `turn ${index} to end`, + ); + }; + + for (let i = 0; i < 3; i += 1) { + await turn(i); + } + expect(asked).toBe(3); + expect(events.some((e) => e.kind === 'model_resolved')).toBe(false); + + // 明示モデルを選び、また既定へ戻す。 + await run.setModel?.('gpt-5.4-mini'); + await run.setModel?.(undefined); + await turn(3); + + expect(asked).toBe(4); + expect(events.find((e) => e.kind === 'model_resolved')).toEqual({ + kind: 'model_resolved', + model: 'gpt-5.6-sol', + }); + + prompts.close(); + await done; + }); + it('survives a failed lookup (the model column just stays empty)', async () => { const codex = makeFakeCodex(); const adapter = createCodexAdapter({ diff --git a/src/core/codex-adapter.ts b/src/core/codex-adapter.ts index b034da3..bf84841 100644 --- a/src/core/codex-adapter.ts +++ b/src/core/codex-adapter.ts @@ -304,8 +304,15 @@ export function createCodexAdapter(deps: { model = next; // 「既定に戻す」を選んだら、その CLI 既定が何なのかを次のターンで引き直す // (前回の解決結果は、明示指定していた別モデルのものかもしれない)。 + // + // **探索予算も戻す**。`MAX_MODEL_PROBES` は「そもそも rollout を読めない環境で + // 毎ターン探し回らない」ための上限で、ユーザーが明示的に既定へ戻す操作まで + // 縛るためのものではない。残したままだと、序盤に空振りして予算を使い切った + // セッションでは「明示モデルを選ぶ → 既定へ戻す」としてもモデル欄が明示モデルの + // 表示から二度と更新されない(予算はユーザーの操作回数で自然に頭打ちになる)。 if (next === undefined) { probedThread = undefined; + probeAttempts = 0; } }, }; From 3ee8f2941b0e7b60fce4f72d9df4953fcb50293c Mon Sep 17 00:00:00 2001 From: takecchi Date: Wed, 12 Aug 2026 07:06:40 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E5=8F=A4=E3=81=84=E6=97=A5=E4=BB=98?= =?UTF-8?q?=E3=81=AE=20rollout=20=E3=82=82=E6=8E=A2=E3=81=9B=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B=EF=BC=88=E6=8E=A2?= =?UTF-8?q?=E7=B4=A2=E3=81=AE=E6=97=A5=E6=95=B0=E4=B8=8A=E9=99=90=E3=82=92?= =?UTF-8?q?=E6=92=A4=E5=BB=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー指摘の修正。`ROLLOUT_MAX_DAY_DIRS = 8` により新しい日付ディレクトリを 8 個しか 見ていなかったため、**古いセッションを復元・resume したときにモデルを永久に解決できない**。 `codex exec resume` はスレッドを開始した日に作られた同じ rollout へ追記し続ける一方、 codiva のセッションは `.codiva/state.json` に無期限で残って何日も後に復元される。 上限は暦日ではなく「存在する日付ディレクトリ数」なので、間に別セッションを 9 日ぶん 作っただけで対象が探索範囲から外れる(利用頻度が高いほど早く再現する)。リトライしても 探索範囲は同じなのでモデル欄は空のまま。 - 日数の上限を撤廃し、**新しい順に見て最初に当たった時点で止める**(当日始まった スレッドなら readdir 数回。全日付を舐めるのは「本当に無い」ときだけ) - 一度見つけたパスは使い回す。rollout は追記されるだけで移動しないので、リトライは 同じ 1 ファイルの読み直しで済み、走査を待ち時間のたびに繰り返さない - 「本当に無い」ときの走査の繰り返しは `MAX_ROLLOUT_SEARCHES`(3 回)で抑え、 それ以降は待たずに諦める(ファイルはスレッド開始時に作られるので、待っても現れない) 回帰テスト付き(指摘どおりの再現ケース: 対象を `08/01`、別スレッドを `08/02`〜`08/10` に 置いて解決できること + 年月をまたぐケース。旧上限のコードで落ちることを確認)。 --- docs/TECH_NOTES.md | 8 +++++ src/utils/codex.spec.ts | 20 +++++++++++++ src/utils/codex.ts | 65 ++++++++++++++++++++++++++--------------- 3 files changed, 70 insertions(+), 23 deletions(-) diff --git a/docs/TECH_NOTES.md b/docs/TECH_NOTES.md index 5ddfec2..c645b6c 100644 --- a/docs/TECH_NOTES.md +++ b/docs/TECH_NOTES.md @@ -1065,6 +1065,14 @@ Claude の `system/init` に当たるもの(**実際に動いているモデ `turn_context` はその数行あとに来るので**先頭 512KB だけ**読む。 - `turn_context` は**ターン開始時**に書かれるので、`thread.started` の直後にはまだ無いことが ある。数回だけ間を置いて読み直す(実測では当日ディレクトリに当たって **4ms** で解決した)。 + 一度見つけたパスは使い回す(追記されるだけで移動しないので、リトライは同じ 1 ファイルの + 読み直しで済み、走査を待ち時間のたびに繰り返さない)。 +- **探索を日数で打ち切らない。** `codex exec resume` は**スレッドを開始した日**の rollout へ + 追記し続ける一方、codiva のセッションは `state.json` に無期限で残って何日も後に復元される。 + 「新しい日付から N 個まで」で切ると、その間に別セッションを作っただけで対象が範囲から外れ、 + モデル欄が二度と埋まらない(暦日ではなく**存在する日付ディレクトリ数**なので、使うほど早く + 当たる)。代わりに**新しい順に見て最初に当たった時点で止める**ことで実費を抑え、全日付を + 舐めるのは「本当に無い」ときだけにする(その繰り返しは探索回数の上限で抑える)。 - **カタログ先頭(priority 1)を既定とみなす手は採らない。** 実測では確かに priority 1 = `gpt-5.6-sol` = 実際の既定だったが、`~/.codex/config.toml` で `model` を 設定しているユーザーには**嘘のモデル名**を出すことになる。 diff --git a/src/utils/codex.spec.ts b/src/utils/codex.spec.ts index d1dfc7a..b3d3e7c 100644 --- a/src/utils/codex.spec.ts +++ b/src/utils/codex.spec.ts @@ -246,6 +246,26 @@ describe('resolveCodexRolloutModel', () => { expect(await resolveCodexRolloutModel(THREAD, { home, ...fast })).toBe('gpt-5.4-mini'); }); + // `codex exec resume` は**スレッド開始時に作られた同じ rollout** へ追記し続けるので、 + // 何日も経ってから復元・resume したセッションのファイルは古い日付ディレクトリにある。 + // 日数で探索を打ち切ると、その間に別セッションを作っただけでモデル欄が二度と埋まらない。 + it('finds a resumed thread whose rollout sits in a much older day directory', async () => { + await writeRollout(home, ['2026', '08', '01'], THREAD, [meta, turn('gpt-5.6-sol')]); + // 開始日以降に別スレッドのセッションが 9 日ぶん積まれている。 + for (let day = 2; day <= 10; day += 1) { + const dd = String(day).padStart(2, '0'); + await writeRollout(home, ['2026', '08', dd], `other-${dd}`, [meta, turn('gpt-5.4-mini')]); + } + expect(await resolveCodexRolloutModel(THREAD, { home, ...fast })).toBe('gpt-5.6-sol'); + }); + + // 年・月をまたいでも同じ(新しい順に見て最初に当たった時点で止める)。 + it('crosses year and month boundaries when looking for an old thread', async () => { + await writeRollout(home, ['2025', '12', '30'], THREAD, [meta, turn('gpt-5.6-sol')]); + await writeRollout(home, ['2026', '08', '11'], 'recent-thread', [meta, turn('gpt-5.4-mini')]); + expect(await resolveCodexRolloutModel(THREAD, { home, ...fast })).toBe('gpt-5.6-sol'); + }); + it('gives up quietly when the thread has no rollout yet (model column stays empty)', async () => { await writeRollout(home, ['2026', '08', '11'], 'someone-else', [meta, turn('gpt-5.6-sol')]); expect(await resolveCodexRolloutModel(THREAD, { home, ...fast })).toBeUndefined(); diff --git a/src/utils/codex.ts b/src/utils/codex.ts index f5fa77c..1f7c3ca 100644 --- a/src/utils/codex.ts +++ b/src/utils/codex.ts @@ -211,13 +211,19 @@ export async function fetchCodexModelCatalog(opts?: { */ const ROLLOUT_HEAD_BYTES = 512 * 1024; -/** 何日ぶんの日付ディレクトリまで遡って探すか(当日に無ければ日付跨ぎを疑う程度)。 */ -const ROLLOUT_MAX_DAY_DIRS = 8; - /** `turn_context` が書かれるまで待つリトライ(間隔 × 回数)。 */ const ROLLOUT_RETRY_MS = 200; const ROLLOUT_RETRIES = 6; +/** + * ファイル探索そのものを何回まで繰り返すか。 + * + * リトライの主目的は「`turn_context` がまだ書かれていない」を待つことで、**ファイル自体は + * スレッド開始時に作られる**(`session_meta` が先に書かれる)。見つからないまま数回試して + * 駄目なら、待っても現れないので早めに諦める(全日付を舐める走査を無駄に繰り返さない)。 + */ +const MAX_ROLLOUT_SEARCHES = 3; + /** `$CODEX_HOME`(未設定なら `~/.codex`)。 */ function codexHome(): string { const home = process.env.CODEX_HOME?.trim(); @@ -234,34 +240,35 @@ async function subdirsDesc(dir: string): Promise { .reverse(); } -/** `sessions/` 以下の日付ディレクトリを新しい順に列挙する(上限付き)。 */ -async function rolloutDayDirs(sessionsDir: string, limit: number): Promise { - const days: string[] = []; +/** + * そのスレッドの rollout ファイルを探す(見つからなければ undefined)。 + * + * **日数で打ち切らない。** `codex exec resume` はスレッド開始時に作られた同じ + * rollout へ追記し続けるので、ファイルは常に**スレッドを開始した日**のディレクトリに + * ある。一方 codiva のセッションは `.codiva/state.json` に無期限で残り、何日も経ってから + * 復元・resume される。日数(正確には「存在する日付ディレクトリ数」)で上限を置くと、 + * その間に別セッションを作っただけで対象が探索範囲から外れ、モデル欄が二度と埋まらない。 + * + * 代わりに**新しい日付から見て最初に当たった時点で止める**ことで実費を抑える + * (当日始まったスレッドなら readdir 数回で終わる)。全日付を舐めるのは「そのスレッドの + * rollout が本当に無い」ときだけで、その繰り返しは {@link MAX_ROLLOUT_SEARCHES} が抑える。 + */ +async function findRollout(home: string, threadId: string): Promise { + const sessionsDir = join(home, 'sessions'); for (const year of await subdirsDesc(sessionsDir)) { const yearDir = join(sessionsDir, year); for (const month of await subdirsDesc(yearDir)) { const monthDir = join(yearDir, month); for (const day of await subdirsDesc(monthDir)) { - days.push(join(monthDir, day)); - if (days.length >= limit) { - return days; + const dayDir = join(monthDir, day); + const names = await readdir(dayDir).catch(() => []); + const hit = names.find((name) => isCodexRolloutFile(name, threadId)); + if (hit) { + return join(dayDir, hit); } } } } - return days; -} - -/** そのスレッドの rollout ファイルを探す(新しい日付から。見つからなければ undefined)。 */ -async function findRollout(home: string, threadId: string): Promise { - const sessionsDir = join(home, 'sessions'); - for (const dayDir of await rolloutDayDirs(sessionsDir, ROLLOUT_MAX_DAY_DIRS)) { - const names = await readdir(dayDir).catch(() => []); - const hit = names.find((name) => isCodexRolloutFile(name, threadId)); - if (hit) { - return join(dayDir, hit); - } - } return undefined; } @@ -303,12 +310,24 @@ export async function resolveCodexRolloutModel( const home = opts?.home ?? codexHome(); const retries = opts?.retries ?? ROLLOUT_RETRIES; const retryMs = opts?.retryMs ?? ROLLOUT_RETRY_MS; + // 一度見つけたパスは使い回す。rollout はスレッド開始時に作られて以後追記されるだけ + // なので途中で変わらない = リトライは**同じ 1 ファイルを読み直すだけ**でよく、 + // 全日付を舐める走査を待ち時間のたびに繰り返さずに済む。 + let path: string | undefined; + let searches = 0; for (let attempt = 0; attempt <= retries; attempt += 1) { if (attempt > 0) { await sleep(retryMs); } try { - const path = await findRollout(home, threadId); + if (path === undefined) { + if (searches >= MAX_ROLLOUT_SEARCHES) { + // ファイルが無い(rollout を残さない設定・別レイアウト)。待っても現れない。 + return undefined; + } + searches += 1; + path = await findRollout(home, threadId); + } const text = path ? await readHead(path) : undefined; const model = text ? codexRolloutModelFromText(text) : undefined; if (model) {