diff --git a/.claude/rules/sdk-integration.md b/.claude/rules/sdk-integration.md index fcef9eb..64eb143 100644 --- a/.claude/rules/sdk-integration.md +++ b/.claude/rules/sdk-integration.md @@ -50,6 +50,11 @@ 報告は **`model_resolved`**(`core/agent-events.ts`)で行う。答えはターンが終わったあとに 届くことがあり、`session_started` / `assistant_message` に相乗りさせると `status` が `running` に巻き戻るため、**モデル欄だけを触る専用イベント**を通す。 + **その答えが「いつ書かれるか」も実測で確かめる。** Codex は `thread.started` から + 約 3 秒遅れて rollout に書くので、直後に少し待つだけの問い合わせは必ず空振りする。 + だから「ターン中に長い猶予で 1 本(誰も await しない)+ **ターンが終わってから短い猶予で + 引き直す**(そこでは必ず書かれている)」の 2 段にする — 前者だけだと、一度指示して結果を + 待つだけのセッション(いちばん普通の使い方)のモデル欄が永久に埋まらない。 - **CLI は同梱せず、ユーザーがインストールしたものを起動する**(`git` / `gh` と同じ扱い)。 provider の SDK パッケージを依存に足すと、その provider を使わないユーザーにまで プラットフォーム別バイナリを配ることになる。認証もその CLI のログインに委ねる diff --git a/docs/TECH_NOTES.md b/docs/TECH_NOTES.md index b3234ff..64737cf 100644 --- a/docs/TECH_NOTES.md +++ b/docs/TECH_NOTES.md @@ -1063,16 +1063,36 @@ Claude の `system/init` に当たるもの(**実際に動いているモデ `thread.started` の `thread_id`** なので、時刻部分(ローカル時刻)は当てにせず id で突き合わせる。 - 1 ファイルは実測**平均 1MB 超**(`session_meta` が `base_instructions` 全文を 1 行で運ぶ)。 `turn_context` はその数行あとに来るので**先頭 512KB だけ**読む。 -- `turn_context` は**ターン開始時**に書かれるので、`thread.started` の直後にはまだ無いことが - ある。数回だけ間を置いて読み直す(実測では当日ディレクトリに当たって **4ms** で解決した)。 - 一度見つけたパスは使い回す(追記されるだけで移動しないので、リトライは同じ 1 ファイルの +- **`turn_context` は「ターン開始時」ではなく数秒遅れて書かれる**。実バイナリを走らせて + 計測した順序(0.147.0、`Reply with just the word OK.` の 1 ターン): + + | 時刻(`thread.started` 基準) | 起きること | + |---|---| + | +0.0s | stdout に `thread.started` | + | ~+0.2s | rollout ファイルが作られる(`session_meta` = `base_instructions` 全文が先に書かれる) | + | **~+3.0s** | **`turn_context`(モデル slug)が書かれる**(入力 `response_item` と `world_state` のあと) | + | +7.2s | `turn.completed` | + + 当初は「200ms × 6 回 = 最長 1.2 秒」(しかもファイル探索の上限で実質 0.6 秒)で諦めていたため、 + **既定モデルで動く Codex セッションのモデル欄はほぼ必ず空のまま**だった(#121 で入れた仕組みが + 実際には一度も答えを拾えていなかった)。読み直しの猶予は実測の数倍取り(既定 20 秒・間隔は + 指数バックオフ)、**探索回数の上限は置かない**(猶予そのものが上限。ファイルが作られる前に + 張った問い合わせを、出現を待てずに諦めさせないため)。 + 一度見つけたパスは使い回す(追記されるだけで移動しないので、読み直しは同じ 1 ファイルの 読み直しで済み、走査を待ち時間のたびに繰り返さない)。 +- **本命は「ターンが終わったあとの引き直し」**。上の遅れがあるため、ターン中に張った問い合わせは + 短いターンだと空振りしうる。一方**ターンが終わった時点なら必ず書かれている**ので、終端イベントを + 流したあとに短い猶予(1.5 秒)で聞き直す(`core/codex-adapter.ts`)。ここが無いと 1 ターンで + idle になったセッション(= 一度指示して結果を待つ、いちばん普通の使い方)のモデル欄が永久に + 空のままになる。ターン中の問い合わせは**誰も await しない**ので猶予を長く取れる代わりに、 + 終了後の引き直しだけは**待つ**ので短くする(取れない環境でターンの合間を引き止めない)。 - **探索を日数で打ち切らない。** `codex exec resume` は**スレッドを開始した日**の rollout へ 追記し続ける一方、codiva のセッションは `state.json` に無期限で残って何日も後に復元される。 「新しい日付から N 個まで」で切ると、その間に別セッションを作っただけで対象が範囲から外れ、 モデル欄が二度と埋まらない(暦日ではなく**存在する日付ディレクトリ数**なので、使うほど早く 当たる)。代わりに**新しい順に見て最初に当たった時点で止める**ことで実費を抑え、全日付を - 舐めるのは「本当に無い」ときだけにする(その繰り返しは探索回数の上限で抑える)。 + 舐めるのは「本当に無い」ときだけにする(その繰り返しは猶予とバックオフ、および + 「空振りしてよい回数」の予算で抑える)。 - **カタログ先頭(priority 1)を既定とみなす手は採らない。** 実測では確かに priority 1 = `gpt-5.6-sol` = 実際の既定だったが、`~/.codex/config.toml` で `model` を 設定しているユーザーには**嘘のモデル名**を出すことになる。 diff --git a/src/bootstrap/build-manager.ts b/src/bootstrap/build-manager.ts index 4d78def..0a21a42 100644 --- a/src/bootstrap/build-manager.ts +++ b/src/bootstrap/build-manager.ts @@ -92,7 +92,8 @@ export function buildAgents( checkAvailability: () => detectCodexAvailability(), // `codex exec --json` は解決済みモデルを運ばない。既定モデルで動いている // セッションのモデル名は rollout の `turn_context` からしか分からない。 - resolveModel: (threadId) => resolveCodexRolloutModel(threadId), + // `waitMs` はアダプタが決める(ターン中は長く、ターン終了後の引き直しは短く)。 + resolveModel: (threadId, waitMs) => resolveCodexRolloutModel(threadId, { waitMs }), spawnLogin, }), }; diff --git a/src/core/codex-adapter.spec.ts b/src/core/codex-adapter.spec.ts index 869488a..25704ae 100644 --- a/src/core/codex-adapter.spec.ts +++ b/src/core/codex-adapter.spec.ts @@ -396,8 +396,8 @@ describe('createCodexAdapter resolveModel', () => { }); const adapter = createCodexAdapter({ spawn: codex.spawn, - resolveModel: (threadId) => { - seen.push(threadId); + resolveModel: (threadId, waitMs) => { + seen.push(`${threadId}:${waitMs > 5_000 ? 'long' : 'short'}`); return pending; }, }); @@ -414,7 +414,9 @@ describe('createCodexAdapter resolveModel', () => { answer('gpt-5.6-sol'); await waitFor(() => events.some((e) => e.kind === 'model_resolved'), 'the resolved model'); - expect(seen).toEqual(['th-1']); + // ターン中は長い猶予で聞き(誰も待たない)、ターン終了後は短い猶予で引き直す + // (そこでは既に書かれているので当たれば即返る)。 + expect(seen).toEqual(['th-1:long', 'th-1:short']); expect(events.find((e) => e.kind === 'model_resolved')).toEqual({ kind: 'model_resolved', model: 'gpt-5.6-sol', @@ -553,11 +555,41 @@ describe('createCodexAdapter resolveModel', () => { expect(events.some((e) => e.kind === 'model_resolved')).toBe(false); }); + // **これが本命の回帰テスト。** CLI が解決済みモデルを書き出すのは `thread.started` から + // 約 3 秒後(実測 0.147.0)なので、ターン中に張った問い合わせは空振りしうる。ターンが + // 終わった時点なら必ず書かれているので、そこで引き直せば当たる。これが無いと + // 「Codex でセッションを始めてもモデル欄が空のまま」になる(実際にそうなっていた)。 + it('re-asks at the end of the turn when the in-turn lookup was too early', 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); + + // 1 ターンで終わって idle になるセッション(次の指示は来ない)。 + prompts.push('only turn'); + 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 === 'model_resolved'), 'the tail lookup'); + expect(asked).toBe(2); + const kinds = events.map((e) => e.kind); + expect(kinds.indexOf('model_resolved')).toBeGreaterThan(kinds.indexOf('turn_completed')); + + prompts.close(); + await done; + }); + // 空振り(`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']; + // ターン 1 は 2 回(ターン中 + 終了後)とも空振りし、ターン 2 で読めるようになる。 + const answers: (string | undefined)[] = [undefined, undefined, 'gpt-5.6-sol']; let asked = 0; const adapter = createCodexAdapter({ spawn: codex.spawn, @@ -579,7 +611,7 @@ describe('createCodexAdapter resolveModel', () => { 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); + expect(asked).toBe(3); prompts.close(); await done; @@ -609,7 +641,9 @@ describe('createCodexAdapter resolveModel', () => { `turn ${turn} to end`, ); } - expect(asked).toBe(3); + // 予算は 4 回ぶん(1 ターンにつきターン中 + 終了後の 2 回)。5 ターン回しても + // それ以上は探しに行かない。 + expect(asked).toBe(4); prompts.close(); await done; @@ -626,8 +660,8 @@ describe('createCodexAdapter resolveModel', () => { spawn: codex.spawn, resolveModel: async () => { asked += 1; - // 最初の 3 回(= 上限ぶん)は空振り、そのあとは読める。 - return asked > 3 ? 'gpt-5.6-sol' : undefined; + // 最初の 4 回(= 上限ぶん)は空振り、そのあとは読める。 + return asked > 4 ? 'gpt-5.6-sol' : undefined; }, }); const { run, prompts, events, done } = drive(adapter); @@ -644,10 +678,11 @@ describe('createCodexAdapter resolveModel', () => { ); }; + // 2 ターン(= 4 回の問い合わせ)で予算を使い切る。3 ターン目は探しに行かない。 for (let i = 0; i < 3; i += 1) { await turn(i); } - expect(asked).toBe(3); + expect(asked).toBe(4); expect(events.some((e) => e.kind === 'model_resolved')).toBe(false); // 明示モデルを選び、また既定へ戻す。 @@ -655,7 +690,7 @@ describe('createCodexAdapter resolveModel', () => { await run.setModel?.(undefined); await turn(3); - expect(asked).toBe(4); + expect(asked).toBe(5); expect(events.find((e) => e.kind === 'model_resolved')).toEqual({ kind: 'model_resolved', model: 'gpt-5.6-sol', diff --git a/src/core/codex-adapter.ts b/src/core/codex-adapter.ts index bf84841..cffb8af 100644 --- a/src/core/codex-adapter.ts +++ b/src/core/codex-adapter.ts @@ -28,11 +28,23 @@ import type { CodexSandbox, EffortLevel } from './config'; */ /** - * 解決済みモデルの問い合わせを 1 セッションで何回まで試すか。空振り(rollout が - * まだ書かれていない)は次のターンで引き直すが、そもそも取れない環境 - * (rollout を書かない設定・別レイアウト)で毎ターン探し回らないための上限。 + * 解決済みモデルの問い合わせが**空振り**してよい回数の上限。そもそも取れない環境 + * (rollout を書かない設定・別レイアウト)で毎ターン探し回らないための予算で、 + * 1 ターンにつき最大 2 回(ターン中 + ターン終了後の引き直し)消費する。 */ -const MAX_MODEL_PROBES = 3; +const MAX_MODEL_PROBES = 4; + +/** + * 問い合わせに与える猶予(実 I/O 側の `resolveCodexRolloutModel` へ渡す)。 + * + * - **ターン中**(`thread.started` の直後に張る)は長く待つ。CLI が解決済みモデルを + * 書き出すのは実測で `thread.started` から**約 3 秒後**なので、短く切ると必ず空振りする。 + * この問い合わせは**誰も await しない**(ストリームを止めない)ので長くても害が無い。 + * - **ターン終了後の引き直し**は短くする。そこでは既に書かれているので普通は 1 回で + * 当たり、取れない環境ではターンの合間を長く引き止めない(ここだけは await する)。 + */ +const MODEL_PROBE_WAIT_MS = 20_000; +const MODEL_PROBE_TAIL_WAIT_MS = 1_500; /** 1 ターンぶんの `codex exec` を起動するための入力(I/O 実装は `utils/codex.ts`)。 */ export interface CodexSpawnRequest { @@ -106,8 +118,11 @@ export function createCodexAdapter(deps: { * `resolveCodexRolloutModel`)。`codex exec --json` はモデル名を運ばないので、 * `--model` を明示していないセッションはこれが唯一の出所になる。 * 省略可(渡さなければモデル欄は空のまま = 従来どおり)。 + * + * `waitMs` は「答えが出るまで待ってよい上限」。答えは CLI が非同期に書き出すので、 + * 待たずに聞くと空振りする({@link MODEL_PROBE_WAIT_MS})。 */ - resolveModel?: (threadId: string) => Promise; + resolveModel?: (threadId: string, waitMs: number) => Promise; /** TUI 内ログインのプロセス起動(I/O は `utils/agent-login.ts` の `spawnLogin`)。 */ spawnLogin?: (command: string, args: readonly string[]) => AgentLoginProcess; }): AgentAdapter { @@ -141,43 +156,51 @@ export function createCodexAdapter(deps: { // `--model` を明示していないセッション(= CLI の既定に任せている)のモデル欄は // これでしか埋まらない。**ストリームは止めない** — 問い合わせは走らせておいて、 // answered ぶんを後続イベントの合間に流す。 - let modelProbe: Promise | undefined; let pendingModel: string | undefined; - let probedThread: string | undefined; - let probeAttempts = 0; + /** ターン中に張っている問い合わせ(1 本だけ)。 */ + let inTurnProbe: Promise | undefined; + /** 一度でも読めたか(読めたらそのセッションでは以後探さない)。 */ + let resolvedOnce = false; + /** 空振り(+ 失敗)した回数。{@link MAX_MODEL_PROBES} で打ち切る。 */ + let misses = 0; - const probeModel = (id: string): void => { + /** + * 問い合わせを 1 本張る(呼ばなくてよいときは undefined を返す)。 + * + * **空振りは記憶しない** — 答えは CLI が遅れて書き出すので、早すぎた問い合わせが + * 空振りするのは正常な経路。latch すると「次に聞けば読めるのに二度と聞かない」 + * セッションになる。歯止めは回数の予算({@link MAX_MODEL_PROBES})だけにする。 + */ + const startProbe = (id: string, waitMs: number): Promise | undefined => { const resolve = deps.resolveModel; // 明示指定があるときは Session が先に表示済みなので問い合わせない。 - if (!resolve || model !== undefined || probedThread === id || modelProbe) { - return; - } - // 取れない環境(rollout を書かない設定・別レイアウト)で毎ターン探し回らない。 - if (probeAttempts >= MAX_MODEL_PROBES) { - return; + if (!resolve || model !== undefined || resolvedOnce || misses >= MAX_MODEL_PROBES) { + return undefined; } - probeAttempts += 1; - probedThread = id; - // **空振りは記憶しない**。`turn_context` はターン開始時に書かれるので、初回が - // 早すぎて空振りすることがある。ここで id を latch したままにすると、次の - // ターンなら即座に読めるのに二度と引き直さないセッションになる。 - const retryLater = () => { - probedThread = undefined; - }; - modelProbe = resolve(id) + return resolve(id, waitMs) .then((found) => { if (found === undefined) { - retryLater(); + misses += 1; return; } + resolvedOnce = true; pendingModel = found; }) - .catch(retryLater) - .finally(() => { - modelProbe = undefined; + .catch(() => { + misses += 1; }); }; + /** ターン中の問い合わせ(走っていれば何もしない。答えは後続イベントの合間に流れる)。 */ + const probeDuringTurn = (id: string): void => { + if (inTurnProbe) { + return; + } + inTurnProbe = startProbe(id, MODEL_PROBE_WAIT_MS)?.finally(() => { + inTurnProbe = undefined; + }); + }; + /** * 解決済みモデルを 1 回だけ流す。**答えが古くなっていたら捨てる** — 問い合わせ中に * `/model` で明示選択されたり(そちらが正しい)、中断・エージェント切替が起きたり @@ -242,7 +265,7 @@ export function createCodexAdapter(deps: { } if (event.type === 'thread.started') { threadId = event.thread_id; - probeModel(event.thread_id); + probeDuringTurn(event.thread_id); } else if (event.type === 'turn.completed' || event.type === 'turn.failed') { sawTerminal = true; } @@ -271,17 +294,25 @@ export function createCodexAdapter(deps: { : { kind: 'turn_stopped', cause: classifyCodexError(detail), detail }; } - // 問い合わせの回収は**終端イベントを流したあと**。`turn_context` は - // ターン開始時に書かれるので、短いターンだと解決がストリームの終わりに - // 間に合わない。ここで待たないと、1 ターンで終わって idle になった - // セッションのモデル欄が次の指示まで空のままになる。`model_resolved` は + // 問い合わせの回収は**終端イベントを流したあと**。`model_resolved` は // status を触らないので、完了イベントの後に流しても巻き戻さない。 // - // 中断・破棄のときは**待たない**(`Ctrl+C` の直後や終了処理を、表示用の - // 問い合わせで最大 1 秒引き止めない)。タイマーは unref してあるので、 + // **ここで引き直すのが本命**。CLI が解決済みモデルを書き出すのは + // `thread.started` から約 3 秒後(実測)なので、ターン中に張った + // 問い合わせは空振りしうる。一方**ターンが終わった時点なら必ず書かれている** + // ので、ここで聞けば当たる。これが無いと 1 ターンで idle になった + // セッションのモデル欄が次の指示まで(1 回きりのセッションなら永久に) + // 空のままになる — 実際にそうなっていた。 + // + // 走っている問い合わせは**待たない**(あちらは長い猶予を持つので、待つと + // ターンの合間を最大その猶予ぶん引き止める)。同じファイルを読むだけなので + // 二重に走っても害は無い。 + // + // 中断・破棄のときは**そもそも聞かない**(`Ctrl+C` の直後や終了処理を、 + // 表示用の問い合わせで引き止めない)。タイマーは unref してあるので、 // 置き去りにした問い合わせが TUI の終了を止めることもない。 - if (modelProbe && !interrupted && !request.abortController.signal.aborted) { - await modelProbe; + if (threadId && !interrupted && !request.abortController.signal.aborted) { + await startProbe(threadId, MODEL_PROBE_TAIL_WAIT_MS); } const resolved = takeResolvedModel(); if (resolved) { @@ -311,8 +342,8 @@ export function createCodexAdapter(deps: { // セッションでは「明示モデルを選ぶ → 既定へ戻す」としてもモデル欄が明示モデルの // 表示から二度と更新されない(予算はユーザーの操作回数で自然に頭打ちになる)。 if (next === undefined) { - probedThread = undefined; - probeAttempts = 0; + resolvedOnce = false; + misses = 0; } }, }; diff --git a/src/utils/codex.spec.ts b/src/utils/codex.spec.ts index b3d3e7c..84c3867 100644 --- a/src/utils/codex.spec.ts +++ b/src/utils/codex.spec.ts @@ -233,7 +233,8 @@ describe('resolveCodexRolloutModel', () => { await rm(home, { recursive: true, force: true }); }); - const fast = { retries: 1, retryMs: 1 }; + // 「もう書かれている」ケースは 1 回目で当たるので猶予は最小で足りる。 + const fast = { waitMs: 5, pollMs: 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')]); @@ -278,8 +279,27 @@ describe('resolveCodexRolloutModel', () => { 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 }); + const pending = resolveCodexRolloutModel(THREAD, { home, waitMs: 5_000, pollMs: 1 }); await writeRollout(home, ['2026', '08', '11'], THREAD, [meta, turn('gpt-5.6-sol')]); expect(await pending).toBe('gpt-5.6-sol'); }); + + // 実測(0.147.0)では rollout ファイル自体も `thread.started` の**あと**に作られる + // (~0.2 秒)。「数回探して見つからなければ諦める」で打ち切ると、ターン開始と同時に + // 張った問い合わせがファイルの出現を待てずに空振りする。 + it('keeps looking for the rollout file until the deadline (it appears after the call)', async () => { + const pending = resolveCodexRolloutModel(THREAD, { home, waitMs: 5_000, pollMs: 1 }); + await new Promise((resolve) => setTimeout(resolve, 30)); + await writeRollout(home, ['2026', '08', '11'], THREAD, [meta, turn('gpt-5.6-sol')]); + expect(await pending).toBe('gpt-5.6-sol'); + }); + + // 猶予はバックオフしても必ず守る(ターン終了後の引き直しはここで待たせない)。 + it('gives up within the patience window it was given', async () => { + const started = Date.now(); + expect( + await resolveCodexRolloutModel(THREAD, { home, waitMs: 40, pollMs: 5, maxPollMs: 10 }), + ).toBeUndefined(); + expect(Date.now() - started).toBeLessThan(2_000); + }); }); diff --git a/src/utils/codex.ts b/src/utils/codex.ts index 1f7c3ca..88d2dc2 100644 --- a/src/utils/codex.ts +++ b/src/utils/codex.ts @@ -211,18 +211,31 @@ export async function fetchCodexModelCatalog(opts?: { */ const ROLLOUT_HEAD_BYTES = 512 * 1024; -/** `turn_context` が書かれるまで待つリトライ(間隔 × 回数)。 */ -const ROLLOUT_RETRY_MS = 200; -const ROLLOUT_RETRIES = 6; - /** - * ファイル探索そのものを何回まで繰り返すか。 + * `turn_context` が書かれるまで待つ既定の猶予と、読み直しの間隔(指数バックオフ)。 + * + * **実測(codex-cli 0.147.0、`codex exec --json` を実際に走らせて計測)**: + * + * | 時刻(`thread.started` 基準) | 起きること | + * |---|---| + * | +0.0s | stdout に `thread.started` | + * | ~+0.2s | rollout ファイルが作られる(`session_meta` が先に書かれる) | + * | **~+3.0s** | **`turn_context`(モデル slug)が書かれる** | + * | +7.2s | `turn.completed` | * - * リトライの主目的は「`turn_context` がまだ書かれていない」を待つことで、**ファイル自体は - * スレッド開始時に作られる**(`session_meta` が先に書かれる)。見つからないまま数回試して - * 駄目なら、待っても現れないので早めに諦める(全日付を舐める走査を無駄に繰り返さない)。 + * つまり `turn_context` は「ターン開始時」ではなく**入力 `response_item` と + * `world_state` を書き終えたあと**に来る。かつては 200ms × 6 回 = 最長 1.2 秒 + * (しかもファイル探索の上限で実質 0.6 秒)で諦めていたため、**既定モデルで動く + * Codex セッションのモデル欄はほぼ必ず空のまま**だった。猶予は実測の数倍取り、 + * 間隔はバックオフさせて読み直しの回数を抑える。 + * + * ファイル探索の回数にも上限を置かない(猶予そのものが上限になる)。作られる前に + * 張った問い合わせが「数回探して見つからないから諦める」で終わっていたのも、 + * 空振りの一因だった。 */ -const MAX_ROLLOUT_SEARCHES = 3; +const ROLLOUT_WAIT_MS = 20_000; +const ROLLOUT_POLL_MS = 200; +const ROLLOUT_POLL_MAX_MS = 2_000; /** `$CODEX_HOME`(未設定なら `~/.codex`)。 */ function codexHome(): string { @@ -251,7 +264,8 @@ async function subdirsDesc(dir: string): Promise { * * 代わりに**新しい日付から見て最初に当たった時点で止める**ことで実費を抑える * (当日始まったスレッドなら readdir 数回で終わる)。全日付を舐めるのは「そのスレッドの - * rollout が本当に無い」ときだけで、その繰り返しは {@link MAX_ROLLOUT_SEARCHES} が抑える。 + * rollout が本当に無い」ときだけで、その繰り返しは呼び出し側の猶予({@link ROLLOUT_WAIT_MS}) + * とバックオフが抑える。 */ async function findRollout(home: string, threadId: string): Promise { const sessionsDir = join(home, 'sessions'); @@ -299,45 +313,49 @@ const sleep = (ms: number) => * そのスレッドが**実際に使っているモデル**を rollout から読む * (`core/codex-rollout.ts` の理由書きを参照)。 * - * `turn_context` はターンが始まった時点で書かれるので、`thread.started` の直後だと - * まだ無いことがある。数回だけ間を置いて読み直し、それでも読めなければ諦める - * (**throw しない** — モデル欄が空のままになるだけで、セッションは壊さない)。 + * `turn_context` は `thread.started` から**数秒遅れて**書かれる(実測 ~3 秒。 + * {@link ROLLOUT_WAIT_MS} の表)。`waitMs` の猶予いっぱい、バックオフしながら + * 読み直し、それでも読めなければ諦める(**throw しない** — モデル欄が空のままに + * なるだけで、セッションは壊さない)。 + * + * 呼び出し側(`core/codex-adapter.ts`)は猶予を 2 通り使い分ける: ターン中の + * 問い合わせは長く待ってよく(誰も await しない)、ターン終了後の引き直しは短くする + * (そこでは既に書かれているので当たれば即返る)。 */ export async function resolveCodexRolloutModel( threadId: string, - opts?: { home?: string; retries?: number; retryMs?: number }, + opts?: { home?: string; waitMs?: number; pollMs?: number; maxPollMs?: number }, ): Promise { const home = opts?.home ?? codexHome(); - const retries = opts?.retries ?? ROLLOUT_RETRIES; - const retryMs = opts?.retryMs ?? ROLLOUT_RETRY_MS; + const pollMs = Math.max(1, opts?.pollMs ?? ROLLOUT_POLL_MS); + const maxPollMs = Math.max(pollMs, opts?.maxPollMs ?? ROLLOUT_POLL_MAX_MS); + const deadline = Date.now() + Math.max(0, opts?.waitMs ?? ROLLOUT_WAIT_MS); // 一度見つけたパスは使い回す。rollout はスレッド開始時に作られて以後追記されるだけ - // なので途中で変わらない = リトライは**同じ 1 ファイルを読み直すだけ**でよく、 + // なので途中で変わらない = 読み直しは**同じ 1 ファイルを読み直すだけ**でよく、 // 全日付を舐める走査を待ち時間のたびに繰り返さずに済む。 let path: string | undefined; - let searches = 0; - for (let attempt = 0; attempt <= retries; attempt += 1) { - if (attempt > 0) { - await sleep(retryMs); - } + let delay = pollMs; + for (;;) { try { - if (path === undefined) { - if (searches >= MAX_ROLLOUT_SEARCHES) { - // ファイルが無い(rollout を残さない設定・別レイアウト)。待っても現れない。 - return undefined; - } - searches += 1; - path = await findRollout(home, threadId); - } + // まだ見つかっていなければ探索も繰り返す。**「数回で打ち切る」はしない** — + // ファイルが作られる前(`thread.started` 直後)に張った問い合わせが、 + // ファイルの出現を待てずに諦めてしまう。 + path ??= await findRollout(home, threadId); const text = path ? await readHead(path) : undefined; const model = text ? codexRolloutModelFromText(text) : undefined; if (model) { return model; } } catch { - // 権限・レイアウト変更など。次の試行へ(最後まで駄目なら undefined)。 + // 権限・レイアウト変更など。猶予内なら次の試行へ。 } + const left = deadline - Date.now(); + if (left <= 0) { + return undefined; + } + await sleep(Math.min(delay, left)); + delay = Math.min(delay * 2, maxPollMs); } - return undefined; } /** 導入・ログイン確認の上限(サブプロセスが固まっても TUI を止めない)。 */