
「成功したのに失敗扱い」──Claude Code / Codex 複合不具合のデバッグ記録
AIエージェントにコードを書かせる仕組みを自社で開発・運用しています。Claude Code と Codex を目的別に使い分け、GitHub の issue を起点にして planning から implementation までを自動化するシステムです。関連する設計思想はSonnet 4.6 が来た——3回目のモデル更新は会話で気づいたでも触れています。
先日、そのシステムで3つの問題が同時に重なっていた不具合を踏みました。一つひとつは筋の通った原因があるのですが、重なり方が悪くてデバッグに時間がかかったので記録しておきます。
何が起きていたか
実行ログを見ると、implementation フェーズのエージェント処理は79ターン・約16分で完走していました。TypeScript の型チェックも通り、ビルドも通過し、テストも3751/3788パス。どう見ても成功に見える。
なのに、ワークフロー全体は failed で終わっていました。
しばらく見ていると、問題が1つではないことに気づきました。最終的に PR #862 に3つの修正をまとめることになります。
問題1:Claude Code SDK の末尾エラー
ログに2回出ていた [AGENT RESULT]
ログの末尾付近に、こんな行が続けて出ていました。
08:06:23 [AGENT RESULT] status=success, turns=79, duration_ms=959990
08:06:23 [AGENT RESULT] status=error_during_execution, turns=0, duration_ms=0
08:06:23 [ERROR] Execute step failed: Claude Code process exited with code 11行目の status=success は本物の成功です。79ターンで実装が完了したことを示している。2行目の status=error_during_execution は、その直後にゼロターン・ゼロミリ秒で出ています。
「0ターンで失敗」は普通ありえない。これは処理失敗じゃなくて、何か後処理で出たものだと読めました。
SDK のソースを追った
@anthropic-ai/claude-agent-sdk のコードを追うと、本体ループの構造がこうなっていました(擬似化)。
try {
for await (let d of agentLoop({ prompt, tools, ... })) {
// type:"result", subtype:"success" もここで送出される
stream.write(d);
}
} catch (g) {
try {
stream.write({
type: "result",
subtype: "error_during_execution",
duration_ms: 0,
num_turns: 0,
is_error: true,
...
});
} catch {}
process.exit(1);
}agentLoop が success を yield した後、ループの finally 処理や後処理コードがまだ走る。そこで例外が発生すると、catch(g) が拾って2つ目の result を書き、process.exit(1) で終わる。
つまり turns=0, duration_ms=0 は「0ターンで失敗した」のではなく、「catch 節のデフォルト値がそのまま出た」というだけです。
ProcessTransport.readMessages は waitForExit() で子プロセスの終了コードを確認し、非ゼロなら throw します。成果物が揃っていても、exit code 1 を見た時点で一律に失敗扱いになる。
この挙動について整理すると次の通りです。
| 観点 | 判定 |
|---|---|
| 「success の後に2個目の result を出して非ゼロ終了する」設計 | Claude Code 側の不具合(少なくとも改善要) |
| 「内部例外の中身を全く surface しない」 | Claude Code 側の不具合(診断性の欠如) |
| 「そもそも何の例外が出ているか」 | 環境/利用側要因の可能性が高い(stderr を拾わないと特定不能) |
根本トリガーは利用側・環境かもしれないが、それを「失敗したphase」に直結させる挙動はSDK側の設計上の問題、という整理になりました。
修正:success 受信後の stream エラーは warn に降格
sawSuccessResult フラグを立てておき、success 受信後の stream エラーは warning に降格する最小修正です。
// claude-agent-client.ts
const messages: string[] = [];
let sawSuccessResult = false;
try {
for await (const message of stream) {
const sanitizedMessage = sanitizeObjectStrings(message) as SDKMessage;
messages.push(JSON.stringify(sanitizedMessage));
// type=result, subtype=success かつ is_error=false の場合のみ「真の成功」と判定する。
// Claude CLIは認証エラー(401)等でも subtype=success を返すケースがあるため、
// is_errorフィールドで本当の成功/失敗を判別する必要がある。
const m = message as { type?: string; subtype?: string; is_error?: boolean };
if (m.type === 'result' && m.subtype === 'success' && m.is_error !== true) {
sawSuccessResult = true;
}
if (verbose) this.logMessage(message);
}
} catch (err) {
// 成功結果を受信済みなら、SDKの後処理失敗("Claude Code process exited with code 1"など)は
// フェーズ失敗に昇格させず警告に留める。
if (sawSuccessResult) {
logger.warn(
`Claude SDK stream error after successful result (downgraded to warning): ${getErrorMessage(err)}`
);
} else {
throw err;
}
}
return messages;is_error !== true の条件を入れたのは後から判明した理由があります(次の問題で説明します)。
stderr のキャプチャも追加して、次回以降の真因診断を可能にしました。
const stream = query({
prompt,
options: {
// ...
// Claude CLI子プロセスのstderrを取り込み、SDKが握り潰す例外の手掛かりを残す
stderr: (data: string) => {
const trimmed = data.trim();
if (trimmed) {
logger.warn(`[Claude CLI stderr] ${trimmed}`);
}
},
},
});問題2:is_error=true なのに subtype=success が返ってくる
上の修正を入れて再実行すると、今度は別の問題が見えてきました。
[AGENT THINKING] API Error: 401 ... "Invalid authentication credentials" · Please run /login
[AGENT RESULT] status=success, turns=1, duration_ms=1437
[WARNING] Claude SDK stream error after successful result (downgraded to warning): ...Claude Code の OAuth トークンが無効になっていた(401エラー)のに、subtype=success が返ってきていました。そして私の修正が、この「見かけ上の成功」を本物の成功と誤判定して warning に降格してしまっていました。
SDK の型定義を確認すると、SDKResultMessage には is_error: boolean フィールドが存在しています。Claude CLI は認証エラー時に subtype:'success' + is_error:true を返すという、少し混乱する設計になっています。
// SDKResultMessage の型(抜粋)
type SDKResultMessage = SDKMessageBase & {
subtype: 'success';
is_error: boolean;
// ...
}そのため is_error !== true の条件を加えることで、「本物の成功」だけを sawSuccessResult=true にするよう修正しました。
ログフォーマッターにも is_error=true を出力するようにして、次回以降の診断性を上げました。
// 修正後のログ出力例
[AGENT RESULT] status=success, turns=1, duration_ms=1437, is_error=trueこの出力があれば、「401エラー起因の見かけ上の success」だとすぐわかります。
問題3:Codex が毎フェーズ失敗してからフォールバックしていた
Claude 側の問題を追っている間に、Codex のログにも気になるエラーが出ていました。
[INFO ] Using model override for Codex Agent: gpt-5.1-codex-mini
[CODEX ERROR] "The 'gpt-5.1-codex-mini' model is not supported when using Codex with a ChatGPT account."
[WARNING] Falling back to Claude Agent.ChatGPT アカウント経由では gpt-5.1-codex-mini が使えなくなっていました。毎フェーズ、2〜3秒かけて Codex が失敗してから Claude にフォールバックするという無駄が発生していました。
なぜ使えなくなったのか
調査した結果、これは仕様変更です。前日(2026-04-14)に OpenAI が ChatGPT アカウントから gpt-5.2-codex / gpt-5.1-codex-mini を撤去したことがコミュニティや GitHub Issue で確認できました。前日の変更を翌日に踏むというタイミングの悪さでした。
ChatGPT アカウント(codex login)で使えるのは gpt-5.3-codex や gpt-5.4 系になっており、旧モデルは API キー(CODEX_API_KEY)経由なら引き続き使えるとのことでした。
どのモデルに移行するか
max(複雑タスク用)と mini(軽量・低コスト用)のエイリアス先を変更する必要がありました。
mini は gpt-5.4-mini で迷いなく決まりましたが、max は gpt-5.3-codex と gpt-5.4 で少し検討しました。
| 観点 | gpt-5.3-codex | gpt-5.4 |
|---|---|---|
| ポジション | コーディング特化 | メインラインの推奨モデル |
| コンテキスト長 | 小さめ | 1.05Mトークン |
| トークン効率 | 標準 | 複雑タスクで約-47%(実コスト減) |
| 入力単価 | 安い | やや高い |
| 将来性 | 後継モデルへ移行見込み | OpenAI の現行推し |
このシステムは「長尺・多ファイル・複数ターン」型のタスクが多い(今回の実装タスクも79ターン・16分)ので、入力単価より総消費トークン量が重要です。トークン効率が良い gpt-5.4 の方がトータルコストも抑えられる可能性が高いと判断しました。また mini を gpt-5.4-mini にするので、5.4系で揃えるという観点もありました。
// codex-agent-client.ts
export const DEFAULT_CODEX_MODEL = 'gpt-5.4';
export const CODEX_MODEL_ALIASES: Record<string, string> = {
max: 'gpt-5.4', // Default, flagship for complex multi-step projects
mini: 'gpt-5.4-mini', // Lightweight, cost-effective
'5.1': 'gpt-5.1', // General-purpose (legacy)
legacy: 'gpt-5-codex', // Legacy (backward compatibility)
};session-wide disable の実装
モデル移行と並行して、「ChatGPT アカウントでモデルが使えない」ことが一度わかったら、以降のフェーズで Codex をそもそも試みないようにする修正も入れました。
問題は、各フェーズで Codex クライアントのインスタンスが独立して生成されること。1フェーズ目で disable しても、2フェーズ目では別インスタンスが生成されて同じ失敗を繰り返します。
そのため、static フラグでプロセス全体の無効状態を共有するようにしました。
// codex-agent-client.ts
class CodexAgentClient {
// セッション全体(プロセス単位)で共有する無効化フラグ。
// アカウント起因の恒久的な失敗を1回検出したら、
// 別インスタンスでも同じCodex CLIを再試行しないようにする。
private static sessionDisabled = false;
private static sessionDisabledReason = '';
public static markSessionDisabled(reason: string): void {
if (!CodexAgentClient.sessionDisabled) {
CodexAgentClient.sessionDisabled = true;
CodexAgentClient.sessionDisabledReason = reason;
logger.warn(`Codex agent disabled for this session (all instances): ${reason}`);
}
}
public markDisabled(reason: string): void {
if (!this.disabled) {
this.disabled = true;
this.disabledReason = reason;
logger.warn(`Codex agent disabled for this session: ${reason}`);
}
// インスタンスのdisableと同時にセッション全体にも伝播させる
CodexAgentClient.markSessionDisabled(reason);
}
public isDisabled(): boolean {
return this.disabled || CodexAgentClient.sessionDisabled;
}
}上位では「ChatGPT アカウントでモデル非対応」のエラーメッセージを検出したときに markDisabled を呼ぶようにしました。
// agent-executor.ts
if (
this.codex?.markDisabled &&
/not supported when using Codex with a ChatGPT account/i.test(message)
) {
this.codex.markDisabled(
'ChatGPT account: configured Codex model is not supported',
);
}これで1フェーズ目の失敗時点で static フラグが立ち、2フェーズ目以降は isDisabled() が即 true を返してバイナリ起動すらスキップします。毎フェーズ2〜3秒の無駄が1回だけになります。
もう一つの問題:メタデータの状態汚染
上の3つの修正の話とは別に、今回の失敗でもう一つ厄介だったのがメタデータの状態汚染でした。
失敗実行が積み重なると、こういう状態になります。
- 最初の実行:本処理は成功 → しかし SDK が exit code 1 で終了 → フェーズが failed に
- 続く複数回の再実行:認証が401エラーでそもそも動けず、revise(修正試行)を3回空振り
- この時点で metadata が固着:
execute.status=completed、implementation.mdはスケルトン、revise_count=3(上限) - 今回の実行:Codex の review ステップが正常に動き「Phase 4 が未実装」と正しく FAIL 判定 → revise しようとするが、リトライ上限に達していて動けない
12:10:45 [INFO] Phase implementation: skipping 'execute' step (already completed)
↑ 空っぽのimplementation.mdで「完了済み」と認識されている
12:12:12 [WARNING] Review failed: Phase 4 実装が未着手(skeleton のまま)
12:12:12 [ERROR] Retry count already at maximum (3/3)
↑ 過去の失敗でreviseリトライを使い切っているexecute は「完了済み」、実装ファイルは「空」、revise は「上限」という三つ巴の詰み状態でした。
解決策は、フェーズを rollback して状態をリセットすること。
node dist/index.js rollback --issue 854 --phase implementation --autoこのコマンドで implementation フェーズを pending に戻し、revise_count もリセットされます。
最終確認
rollback 後に再実行したログを見ると、3つの修正がそれぞれ機能していることが確認できました。
✅ Codexモデル移行(gpt-5.4 / gpt-5.4-mini): reviewが完走
✅ Codex session-disabled: 今回は成功したので発火せず。失敗時は次フェーズ以降スキップ
✅ Claude SDKのstreamエラーdowngrade + is_errorチェック: 今回は発火なし、でも仕込み済この経験から思ったこと
「プロセスが成功を返した」と「終了コードがゼロだった」は別物という事実は、通常のソフトウェア開発でも起きますが、AIエージェントの場合は「成果物が生成された」「内部の後処理が終わった」という2層の完了状態があることで、さらに複雑になります。
exit code だけを信頼するのではなく、本来の成果物(今回なら実装ファイルやビルド結果)の存在を確認してからフェーズ完了を判定する設計にしておくと、このクラスの問題は避けられます。
また、状態の蓄積と失敗の連鎖という問題も印象に残りました。1回目の失敗が「中途半端に完了済み」という状態を残し、2〜3回目の失敗がリトライ上限を消費し、4回目でやっと正しい診断ができる状態になったとき、すでに手詰まりになっていた。
リカバリー設計において、「状態をリセットして最初からやり直せる」手段をきちんと用意しておくことの重要さを再確認しました。rollback コマンド自体は以前から実装していたのですが、これほど使いどころがはっきりする場面があるとは思っていませんでした。
なお、CLAUDE_CODE_OAUTH_TOKEN の401問題(Claude の認証)は引き続き未解決です。今は Codex が動いているので Claude のフォールバックを踏んでいないだけで、根本的な対処はまだ残っています。
参考書籍
Claude Code を実運用する際の考え方や使いこなしを体系的に学びたい方には、以下の書籍が参考になります。
前者は現場での実践・活用法に、後者は AI 駆動開発の全体像に焦点を当てた入門書です。今回のようにエージェントの振る舞いを追いかける場面でも、基礎知識として押さえておくと診断の助けになります。
この記事は役に立ちましたか?

この記事が、何かの整理につながったら
コーヒー1杯分の応援をもらえると嬉しいです。
あなたへのおすすめ
