0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

📋 Codexに「やった証拠」をファイルで残させる3ファイル設計

0
Posted at

月10万の大学生が掛け持ちで月60万まで上げ、会社都合で0に叩き落とされ、半年でClaude Code自律環境を建て直して今は月商120万です。その環境の核心にある「Codexへの作業委譲」を、今回は仕組みレベルで全部開きます。

なぜこの仕組みが効くのか

Codexに作業を投げると「完了しました」と返ってきます。最初はそれで満足していました。でも実際にコードを確認すると、肝心の変更が入っていなかったり、別ファイルを触っていたり、そもそもgit commitが走っていなかったりする。「やった」という出力と「実際にやった」という事実は、別物です。

これはClaude Codeも同じです。ツールの実行結果が正しく読み取れているかどうか、エラーを握りつぶしていないかどうか——自分が書いたコードでさえ、完了宣言の直後にセルフ監査をかけると毎回何かしら見つかります。AIに実装を委譲すると、その問題が一段増幅されます。

解決策は単純で、「言語」でなく「ファイル」に状態を書かせることです。

AIが「完了しました」と言ったとしても、status-fileの State: completed が存在しなければ完了ではない。handoff-fileに git status --short の実出力がなければ、何を変えたか分からない。task-fileに書いた指示通りの4セクション(Summary・Files Changed・Validation・Remaining Risks)がなければ、検証できない。

ファイルは嘘をつきません。AIは圧力をかけると「やりました」と言い張りますが、 cat status-file の出力は偽造できません。状態管理をファイルシステムに落とすことで、人間がシェルで突き合わせ検証できるようになります。これがこの仕組みの本質です。

もう一つ重要なのが、関心の分離です。orchestrate-codex-worker.shは冒頭で3つの引数を受け取ります。

bash scripts/orchestrate-codex-worker.sh <task-file> <handoff-file> <status-file>

この3ファイルには明確な役割があります。

  • task-file: Codexへの作業指示書。「何をやるか」だけを書く。
  • handoff-file: Codexが終えた後の引き継ぎメモ。完了・失敗を問わず、次の担当者(次のClaude Codeセッション、あるいは自分)が読む前提で書き出される。
  • status-file: 機械が読む進行状態。State: runningState: completed / State: failed の3値しか取らない。

この分離がないと何が起きるか。作業指示と実行結果が同じ場所に混在すると、「これは指示文か、それとも実行後の出力か」が曖昧になります。大量のタスクを並列で走らせるとき、この曖昧さは致命的です。複数のCodexワーカーが同じディレクトリで動いても、それぞれのtask/handoff/status-fileが独立していれば干渉しません。

さらに、スクリプトの先頭には set -euo pipefail が入っています。これは「いずれかのコマンドが失敗した瞬間にスクリプト全体を止める」という宣言です。Bashは何も指定しなければエラーを無視して次の行に進みます。set -euo pipefail を入れることで、git rev-parseが失敗したら止まる、mkdirが失敗したら止まる、という挙動になります。エラーを握りつぶさない設計は、AIに任せる前提のスクリプトでは特に重要です。

全体の流れ

スクリプトの動きを順番に追うと、次のようになります。

呼び出し元(Claude Code / cronジョブ等)
    │
    ├─ task-file を渡す(作業指示)
    ├─ handoff-file のパスを渡す(引き継ぎ先)
    └─ status-file のパスを渡す(状態管理先)
         │
         ▼
orchestrate-codex-worker.sh
    │
    ├─ [起動直後] write_status "running"
    │        └→ status-file: State: running / Branch / Worktree / timestamp
    │
    ├─ task-file の読み込み確認
    │   ├─ [失敗] write_status "failed" + handoff-fileにエラー書き出し → exit 1
    │   └─ [成功] 処理継続
    │
    ├─ mktemp で prompt_file / output_file を作成
    │   └─ trap cleanup EXIT(終了時に自動削除)
    │
    ├─ prompt_file を組み立て(Codexへの指示 + task-fileの内容)
    │
    ├─ codex exec -p yolo -m gpt-5.4 -C $(pwd) -o output_file < prompt_file
    │   │
    │   ├─ [成功]
    │   │     handoff-file に書き出し:
    │   │       - Completed: timestamp
    │   │       - Branch: git rev-parse --abbrev-ref HEAD
    │   │       - Worktree: pwd
    │   │       - output_file の内容(Summary/Files Changed/Validation/Remaining Risks)
    │   │       - git status --short
    │   │     write_status "completed"
    │   │
    │   └─ [失敗]
    │         handoff-file に書き出し:
    │           - Failed: timestamp / Branch / Worktree
    │           - "The Codex worker exited with a non-zero status."
    │         write_status "failed" → exit 1
    │
    └─ 完了

実際のコードで核心となる write_status 関数を見ると、何を記録しているかが分かります。

write_status() {
  local state="$1"
  local details="$2"

  cat > "$status_file" <<EOF
# Status

- State: $state
- Updated: $(timestamp)
- Branch: $(git rev-parse --abbrev-ref HEAD)
- Worktree: `$(pwd)`

$details
EOF
}

git rev-parse --abbrev-ref HEAD はその時点のブランチ名を返します。$(pwd) はworktreeの絶対パスです。タイムスタンプは date -u +"%Y-%m-%dT%H:%M:%SZ" でUTC ISO 8601形式になります。

つまりstatus-fileを見れば、いつ・どのブランチで・どのworktreeで・どの状態になったかが一行ずつ分かります。複数のworktreeで並列実行しているとき、status-fileのWorktreeフィールドを見るだけでどれがどれか特定できます。

Codexへ渡すpromptも、スクリプト内で直接組み立てています。

cat > "$prompt_file" <<EOF
You are one worker in an ECC tmux/worktree swarm.

Rules:
- Work only in the current git worktree.
- Do not touch sibling worktrees or the parent repo checkout.
- Complete the task from the task file below.
- Do not spawn subagents or external agents for this task.
- Report progress and final results in stdout only.
- Do not write handoff or status files yourself; the launcher manages those artifacts.
- If you change code or docs, keep the scope narrow and defensible.
- In your final response, include exactly these sections:
  1. Summary
  2. Files Changed
  3. Validation
  4. Remaining Risks

Task file: $task_file

$(cat "$task_file")
EOF

注目点は2つあります。

「Do not write handoff or status files yourself」 という明示的な禁止です。handoff/status-fileを誰が書くかは、設計上の重要な分岐点です。もしCodexに書かせると、Codexは「それっぽい内容」を出力するかもしれません。スクリプト側で書くことで、git status --short の実際の出力、git rev-parse --abbrev-ref HEAD の実際のブランチ名、timestamp の実際の時刻が入ります。これらは改ざんできません。

4セクション強制です。Codexの出力に「exactly these sections」を要求することで、後続の処理やレビューで参照する位置が固定されます。handoff-fileを開いて「Files Changed」セクションを読めば変更ファイルの一覧があり、「Validation」セクションを読めばCodexが実際に実行した検証コマンドとその結果があります。自由形式の出力では、次のセッションでhandoff-fileを読み込んだときにどこを読めばいいか分からなくなります。

codexの呼び出し自体は1行です。

codex exec -p yolo -m gpt-5.4 --color never -C "$(pwd)" -o "$output_file" - < "$prompt_file"

-p yolo は確認プロンプトなし、-m gpt-5.4 はモデル指定、-C "$(pwd)" は作業ディレクトリ、-o "$output_file" は出力先ファイル、- < "$prompt_file" はstdinからプロンプトを読み込む指定です。--color never はANSIエスケープシーケンスをhandoff-fileに混入させないための指定で、後でgrepやパースをするときに余計な文字が入らないようにします。

この呼び出しが成功(exit 0)か失敗(non-zero)かで、その後の分岐が決まります。スクリプトに set -euo pipefail が入っているので、codexコマンドが失敗するとそのままexitせず、if codex exec ...; then ... else ... fi の構造でエラー分岐に入ります。失敗時もhandoff-fileとstatus-fileは必ず書き出されます。ファイルがない=実行すらされていない、という状態を作らないことが、後続の突き合わせ検証に必要だからです。

実装の詳細

trap cleanup EXITが守るもの

前半で触れなかった、最初に読むべき仕掛けがあります。

prompt_file="$(mktemp)"
output_file="$(mktemp)"
cleanup() {
  rm -f "$prompt_file" "$output_file"
}
trap cleanup EXIT

mktemp/tmp/tmp.XXXXXX のような一時ファイルを作ります。trap cleanup EXIT は「スクリプトが終了するとき——exit 0でも exit 1でも——cleanup 関数を実行する」という宣言です。

なぜこれが必要か。codex execprompt_file を読んで output_file に書き出しますが、Codexはときどきnon-zeroで終了します。set -euo pipefail の環境でCodexが失敗すると、スクリプトはelseブロックに入ってexit 1で終わります。そのとき trap がなければ、/tmp/tmp.XXXXXX が残り続けます。1回なら問題なくても、並列で30個のworkerを動かすと /tmp が肥大します。trap cleanup EXIT が入っているので、どのパスで終了しても一時ファイルは消えます。

もう一点、prompt_fileoutput_file をグローバル変数にしていることに注意してください。cleanup 関数は定義時点では $prompt_file / $output_file の中身を知りません。関数が実行される終了時点で変数の値を読みます。だからこそ mktemp の直後に変数に入れてからtrapを張る順序になっています。順序を逆にするとcleanupが空のパスを削ろうとしてエラーになります。

task-fileの存在チェックを「running」より前に置く理由

スクリプトを読むと、task-fileの存在確認は write_status "running" よりも前にあります。

mkdir -p "$(dirname "$handoff_file")" "$(dirname "$status_file")"

if [[ ! -r "$task_file" ]]; then
  write_status "failed" "- Error: task file is missing or unreadable (\`$task_file\`)"
  {
    echo "# Handoff"
    echo
    echo "- Failed: $(timestamp)"
    echo "- Branch: \`$(git rev-parse --abbrev-ref HEAD)\`"
    echo "- Worktree: \`$(pwd)\`"
    echo
    echo "Task file is missing or unreadable: \`$task_file\`"
  } > "$handoff_file"
  exit 1
fi

write_status "running" "- Task file: \`$task_file\`"

write_status "running" は「Codexへの投入が始まった」という意味です。task-fileが読めなければCodexに投入する前提が崩れているので、「running」になる資格がありません。直接 "failed" を書いてexit 1します。

mkdir -p "$(dirname "$handoff_file")" "$(dirname "$status_file")" が最初にある理由も同じです。task-fileが読めなくてfailedを書こうとしたとき、handoff-fileやstatus-fileのディレクトリが存在しなければそれ自体が失敗します。handoff/status-fileへの書き込みより前に、その親ディレクトリを確実に作っておく。これが mkdir -p dirname ... を先頭に置く理由です。

成功時のhandoff-fileが持つもの

Codexのexitコードが0だった場合、handoff-fileには次が書き出されます。

if codex exec -p yolo -m gpt-5.4 --color never -C "$(pwd)" -o "$output_file" - < "$prompt_file"; then
  {
    echo "# Handoff"
    echo
    echo "- Completed: $(timestamp)"
    echo "- Branch: \`$(git rev-parse --abbrev-ref HEAD)\`"
    echo "- Worktree: \`$(pwd)\`"
    echo
    cat "$output_file"
    echo
    echo "## Git Status"
    echo
    git status --short
  } > "$handoff_file"
  write_status "completed" "- Handoff file: \`$handoff_file\`"

cat "$output_file" でCodexの出力全体が入ります。その直後に echo "## Git Status"git status --short が続きます。

この git status --short が検証の要です。Codexが「Files Changed: src/api/index.ts, tests/api.test.ts」とSummaryに書いたとして、git status --short に何も出なければ——gitはそのファイルの変更を認識していない、ということです。

私が実際に使っている突き合わせコマンドは次のようなものです。

# handoff-fileの"## Git Status"以降を確認
grep -A 20 "## Git Status" /path/to/handoff-file

# 実際のgit diffと比較
git diff --stat HEAD

handoff-fileの## Git Statusgit diff --statが一致していれば、Codexが言ったことと実際のgitの状態が合っています。一致しない場合は「Codexが変更を加えたと思っていたが実際は加えていなかった」か、「commitまで済ませていたがgit status --shortには出なかった(=clean)」のどちらかです。後者はstatus-fileの Branch を確認して git log で追います。

--color never をCodexに渡す理由はここにあります。ANSIエスケープシーケンス(\e[32m\033[0m のような制御文字)がoutput_fileに混入すると、handoff-fileにそのまま転記されます。grep -A 20 "## Git Status" handoff-file を実行したとき、見えない制御文字がパターンマッチを壊します。--color never は「出力にANSIコードを含めるな」という指定で、これがないと機械的な後処理が汚染されます。

失敗時に「どうせ何も変わっていない」と思ってはいけない

失敗時のhandoff-fileは最小限です。

else
  {
    echo "# Handoff"
    echo
    echo "- Failed: $(timestamp)"
    echo "- Branch: \`$(git rev-parse --abbrev-ref HEAD)\`"
    echo "- Worktree: \`$(pwd)\`"
    echo
    echo "The Codex worker exited with a non-zero status."
  } > "$handoff_file"
  write_status "failed" "- Handoff file: \`$handoff_file\`"
  exit 1
fi

The Codex worker exited with a non-zero status. の一行だけです。Codexの出力は output_file に途中まで書かれていますが、cat "$output_file" はここでは実行されません。なぜか。

失敗時の output_file は不完全な出力です。4セクションが揃っていないかもしれない。途中でAPIエラーが出ていて、Summary以降が書かれていないかもしれない。不完全な出力をhandoff-fileに混ぜると、次のセッションで読んだとき「これは完了した出力なのか途中の出力なのか」が分からなくなります。失敗は失敗として、内容ゼロで記録する——これが設計の意図です。

失敗後に何が起きていたかを調べるには、output_file ではなく codex exec 実行中のstderrを別途キャプチャする必要があります。このスクリプトではそこまでは書かれていないので、私は失敗時に CODEX_DEBUG=1 などの環境変数でCodex側のログを有効化して別途確認します。


私が詰まった話

set -euo pipefail を入れる前の話

このスクリプトを最初に書いたとき、先頭の set -euo pipefail を入れていませんでした。Bashのデフォルト挙動はエラーを無視して次の行に進みます。

何が起きたか。Codexの呼び出しが失敗したのに、スクリプトは次の if ... then の分岐に進まず(当時はif文でなく直接呼び出していた)、write_status "completed" が実行されました。status-fileには State: completed と書かれています。handoff-fileには Completed: 2026-05-14T08:23:11Z があります。でも git diff --stat には何も出ない。

症状は「status-fileがcompletedなのにコードが変わっていない」です。最初は「Codexが変更を全部コミットしてしまったのか?」と思って git log を見ましたが、そちらにも何もない。

原因を特定するのに30分かかりました。codex exec を手動で走らせてexitコードを確認したら1でした。echo $? で1が返る。でもスクリプト内ではその1を無視して次の行に進んでいた。

直し方は set -euo pipefail を先頭に入れて、Codexの呼び出しを if ... then ... else ... fi で囲むだけです。2行の変更でした。ただ、それを気づくまでに「完了詐称」のstatus-fileを信じて次の作業を積み上げてしまい、後で全部やり直す羽目になりました。

AIに作業を委譲する前提でスクリプトを書くとき、set -euo pipefail はオプションではなく必須条件です。Bashのエラー無視は人間が手作業でスクリプトをデバッグするときは許容できますが、AIへの委譲では「失敗を黙って続行する」が致命的になります。

--color never を入れる前の話

handoff-fileをgrepしようとしたとき、こんな出力が出ました。

## Git Status

?? src/^[[0mapi^[[0m/^[[32mindex.ts^[[0m

^[[0m がANSIのリセットコード、^[[32m が緑色の指定です。Codexのターミナル出力をそのままファイルに吐かせると、ANSIエスケープシーケンスが混入します。

この状態で grep "index.ts" handoff-file を実行すると、パターンは index.ts ですがファイル上は index^[[0m.ts のように分断されているためマッチしません。目で見れば読めますが、スクリプトで処理しようとすると崩れます。

私は当初、後処理で sed 's/\x1b\[[0-9;]*m//g' を使ってANSIコードを除去しようとしました。これは動きますが、すべての端末エスケープシーケンスをカバーするsedパターンは複雑で、将来Codexが違うエスケープを使い始めると壊れます。

根本的な直し方は --color nevercodex exec に渡すことです。Codexに「そもそもANSIコードを出力するな」と指示する。sedで後処理するより、入口で制御する方が単純で堅牢です。

CodexがStatusファイルを自分で書いてしまった話

promptに「作業が終わったらstatus-fileに書き込んでください」という指示を書いていた時期があります。「Codex自身が完了を記録できれば、スクリプト側でif/elseしなくていいのでは」という発想でした。

実際に何が起きたか。Codexは State: completed を書きました。でも内容が本物ではありませんでした。

# Status

- State: completed
- Updated: 2026-05-20T14:33:00Z
- Branch: main
- Worktree: `/path/to/project`

Branchが main です。でも実際のworktreeは feature/api-refactor ブランチにいました。git rev-parse --abbrev-ref HEAD ではなく、Codexが「たぶんmainだろう」と推測して書いた文字列です。タイムスタンプも実際の完了時刻ではなく、Codexが学習データから推測した「それっぽい時刻」でした。

もっと悪かったのは、Codexの呼び出しが途中で失敗していた場合です。Codexはエラー終了の直前に「念のため」status-fileを書こうとすることがあります。その結果、codex exec はnon-zeroで終了したのに、status-fileには State: completed と書かれている——という状態が生まれました。

現在のpromptには明示的に禁止が入っています。

- Do not write handoff or status files yourself; the launcher manages those artifacts.

この一行を入れた理由は「Codexに書かせると、値が実測値でなく推測値になる」からです。$(git rev-parse --abbrev-ref HEAD) はシェルが実際に実行する。Codexが書く main はモデルが推測する。この違いが検証精度を決定的に変えます。状態を記録するファイルは、シェルが書くべきです。

task-fileを相対パスで渡した話

スクリプトを呼び出すときに ./tasks/refactor-api.md のような相対パスでtask-fileを渡したことがあります。このとき mkdir -p "$(dirname "$handoff_file")" の動きがおかしくなりました。

handoff-fileも ./handoffs/refactor-api-handoff.md のような相対パスで渡していたため、スクリプトが cd などで作業ディレクトリを変えると(当時のバージョンではworktreeに cd していた)、dirname が計算したパスが意図と違う場所になりました。

症状は「handoff-fileに書き込めない」という Permission denied または No such file or directory エラーです。デバッグしたとき、dirname の返すパスをechoで確認したら /worktree/subdir/handoffs ではなく /handoffs になっていました。

直し方は呼び出し側で絶対パスに変換して渡すことです。

bash scripts/orchestrate-codex-worker.sh \
  "$(realpath ./tasks/refactor-api.md)" \
  "$(realpath -m ./handoffs/refactor-api-handoff.md)" \
  "$(realpath -m ./status/refactor-api.status.md)"

realpath は既存ファイルの絶対パスを返します。realpath -m はファイルが存在しなくても絶対パスを計算して返します(-m = --no-require-file)。handoffやstatusはスクリプトが作るファイルなので、呼び出し時点では存在しません。realpath -m を使うことで、存在しないファイルの絶対パスを事前に確定できます。

スクリプト内部では $(dirname "$handoff_file") が常に絶対パスのdirnameを計算するので、どのディレクトリからスクリプトを呼んでも安全になりました。

完了詐称を検知するループ

これらの失敗を踏まえて、私が今使っている確認フローは次です。

# 1. status-fileでState確認
grep "State:" /path/to/status-file

# 2. 実際のgit diffと突き合わせ
git diff --stat HEAD

# 3. handoff-fileのGit Statusセクションと比較
grep -A 10 "## Git Status" /path/to/handoff-file

3つが一致したとき、初めて「Codexが言ったことが実際にやられている」と確認できます。

status-fileが State: completed → git diff --stat に変更がある → handoff-fileの## Git Statusに同じファイルが載っている。この3点セットです。

逆に崩れるパターンは決まっています。

  • State: completed なのに git diff --stat が空 → Codexが変更を加えずに「完了」と言った
  • git diff --stat に変更があるのに handoff-fileの## Git Statusに載っていない → スクリプトの書き出し順序にバグがある(私が一度やらかしました)
  • State: failed なのに git diff --stat に変更がある → Codexが途中まで変更を加えてexit 1した(危険なパターン)

最後のパターンが最も注意が必要です。Codexが途中まで変更してクラッシュした場合、git diff --stat には変更が出ますが、その変更が「正しい変更の途中」なのか「壊れた状態」なのかはコードを読むまで分かりません。このとき私は git diff HEAD で差分全体を読んでから、完全に元に戻すか継続するかを判断します。git stash で退避させてから次のCodexワーカーを走らせることが多いです。

handoff-fileが The Codex worker exited with a non-zero status. だけの場合、Codexのエラー原因はstderrにしか残っていません。このスクリプトは現状stderrをキャプチャしていないので、再現させるしかありません。次に同じタスクを投げるとき、task-fileに「前回non-zeroで終了した。エラーの原因を特定してから着手してください」という1行を追加してCodexに渡します。4セクションの「Remaining Risks」にエラーの根因が書かれていることを期待します。

この「ファイルで状態を残す」設計を使い始めてから、Codexへの委譲後に「何が変わったか分からない」という状態がほぼなくなりました。status-fileを cat して、handoff-fileを grep して、git diff --stat を見る——この3コマンドで任意のworkerの完了状態を30秒以内に検証できます。「言っただけ」の完了はファイルシステムに刻まれません。

つまずきポイント

task-fileの指示が「それっぽい日本語」だった

「APIのエラーハンドリングを改善してください」というtask-fileを渡したことがあります。Codexは4セクション形式で返してきました。State: completedも出ています。## Git Statusセクションも存在します。しかしGit Statusの欄は空でした。

handoff-fileのValidationセクションを読んだら「調査した結果、現在の実装で問題ありません」と書かれていました。指示が曖昧なので「改善不要という判断」が「タスク完了」として処理されたわけです。4セクション形式を守りながら何もしないことは、設計の抜け穴として機能します。

task-fileを次の形式に統一してから、このパターンはほぼ消えました。

対象: ~/dev/myapp/src/api/client.ts
やること: fetchUser関数のcatch節でエラーをconsole.errorに出力し、呼び出し元へrethrowする
完了条件: catch節にconsole.error + throw eが入っていること
検証: grep -n "console.error" ~/dev/myapp/src/api/client.ts && grep -n "throw e" ~/dev/myapp/src/api/client.ts

完了条件と検証コマンドがセットで書かれていると、CodexはValidationセクションでそのコマンドを実際に実行してstdoutを貼ります。「調査した結果問題ありません」という逃げが閉じられます。

git worktreeを分離せずに並列で走らせた

orchestrate-codex-worker.shは-C "$(pwd)"で現在のworktreeをCodexに渡します。同じworktreeで2つのworkerを同時に走らせると何が起きるか。

Codex Aがsrc/api/client.tsを書き換えている最中に、Codex Bが同じファイルを読んで別の変更を加えます。Bが先に完了してgit commitを打ちます。AはBのcommit後の状態を知らずに書き出したファイルをstagingに乗せて、さらにcommitします。結果として、Aが意図しない行を上書きしたcommitが残ります。

git diff --statにAの変更とBの変更が混在していて、どちらのworkerがどの行を書いたか追えなくなりました。git log --onelineを見てもcommit間の前後関係が混乱しています。

並列ワーカーを動かすときは必ずgit worktree addでworktreeを分離します。

git worktree add ~/dev/myapp-worker-a feature/api-fix-a
git worktree add ~/dev/myapp-worker-b feature/cache-fix-b

それぞれのworktreeのディレクトリへ移動してからスクリプトを起動すると、status-fileのWorktreeフィールドが~/dev/myapp-worker-a~/dev/myapp-worker-bに分かれ、どのworkerの結果かが一目で分かります。

output_fileが空でhandoff-fileに何も入らなかった

codex execがexit 0で終わったのに、handoff-fileの## Git Statusの手前に何もないことがありました。cat "$output_file"がからのファイルをそのまま転記したため、Codexの4セクション出力が丸ごと欠けた状態です。

原因はCodex API側のタイムアウトでした。処理が長引いてAPIがセッションを切ると、codex execはexit 0で終了することがあります(Codexのバージョンにより挙動が変わります)。output_fileは作成されていますが0バイトです。

対処として2点入れました。

  • codex exectimeout 600 codex exec ...で外側からラップする。10分でkillされてexit 124が返るので、set -euo pipefail環境ではelseブロックに入りfailedが記録されます。ぶら下がりworkerが消えます。
  • handoff-file書き出し直後にwc -c "$handoff_file"でサイズを確認し、200バイト未満なら「出力が空だった可能性」をログに出す。判断は人間がstatus-fileを見てから行います。

Codexが「Remaining Risks: None」を連発した

Codexの4セクション出力を蓄積していくうちに、Remaining RisksセクションにNoneが並ぶことに気づきました。しかし後続のworkerが「前のworkerが実装したコードにバグがあった」と報告するケースが複数出てきています。形式は守るが、内容はポジティブ寄りになる——Codexの傾向としてよく出ます。

task-fileに次の一行を追加してから、具体的なリスクが出てくるようになりました。

- Remaining Risksには必ず1件以上の懸念事項を書くこと。「None」は禁止。

「型定義が暫定です」「テストカバレッジが低い」「本番データでの動作は未検証」のような内容が出てくるようになります。Remaining Risksが常に1件以上あることで、問題の隠蔽という形の完了詐称をひとつ塞げます。

handoff-fileの絶対パスにスペースが混入していた

macOSのホームディレクトリ直下に日本語名のフォルダを作っていた時期、そのパスを含むhandoff-fileのパスを渡したことがあります。"$(dirname "$handoff_file")"はダブルクォートで囲まれているのでスペースに対して安全ですが、呼び出し元のスクリプトでクォートを省略していたため、dirnameがスペースで分断されたパスを受け取りました。

症状はmkdir -pNo such file or directoryです。handoff-fileのディレクトリが作れないので、タスクが始まる前に失敗します。

呼び出し元でのクォート徹底とrealpath化の両方で解決しました。スペースを含むパスをシェルスクリプトに渡すときは、変数代入の右辺でも常にダブルクォートで囲みます。

stderrをキャプチャしていないため失敗原因が残らない

失敗時のhandoff-fileにはThe Codex worker exited with a non-zero status.の一行しかありません。Codexのエラーメッセージはstderrに出ていますが、このスクリプトはstderrをリダイレクトしていないため、端末に出た時点で消えます。

再現させて原因を特定するしかありません。次のtask-fileに「前回non-zeroで終了した。エラーの原因を特定してから着手してください」を追記してCodexに渡します。4セクションの「Remaining Risks」にエラーの根因が書かれることを期待して再投入します。

根本的に解消するにはcodex exec ... 2>"$error_file"でstderrを別ファイルに逃がし、失敗時にhandoff-fileへcat "$error_file"する改修が必要です。現状のスクリプトの未対応領域として把握しておく必要があります。


ベストプラクティス

1. 3引数はすべてrealpath変換してから渡す

task-fileはrealpath(ファイルが存在しなければエラー)、handoff/status-fileはrealpath -m(存在しなくても絶対パスを計算)で変換します。呼び出し時点で相対パスを絶対パスに固定することで、スクリプト内部のdirnameがどのディレクトリから呼ばれても常に正しいパスを計算します。

TASK="$(realpath ./tasks/my-task.md)"
HANDOFF="$(realpath -m ./handoffs/my-task-handoff.md)"
STATUS="$(realpath -m ./status/my-task.status.md)"
bash scripts/orchestrate-codex-worker.sh "$TASK" "$HANDOFF" "$STATUS"

2. task-fileは「完了条件+検証コマンド」まで書く

「〜を改善してください」で終わるtask-fileはCodexに解釈を委ねます。完了条件と実行可能な検証コマンドをセットで書くと、Codexはvalidationセクションでそのコマンドを実際に実行して結果を貼ります。「調査した結果問題なし」という逃げを構造的に閉じられます。

3. timeout 600で外側からラップする

codex execにはCLIレベルのタイムアウト引数がありません。外側からtimeout 600 codex exec ...を被せることで10分でkillされ、exit 124が返ります。set -euo pipefail環境ではfailedとして記録されます。長時間ぶら下がるworkerを作らないための最低限の防御です。

4. 並列ワーカーはgit worktree addで必ず分離する

同じworktreeで複数のworkerを動かさない。git worktree add <path> <branch>でworktreeを分離し、それぞれのworktreeからスクリプトを起動します。status-fileのWorktreeフィールドがworktree別に記録されるため、後から「どのworkerがどのブランチに何を書いたか」を追跡できます。

5. 失敗後はgit stashで退避してから再投入する

State: failedのときgit diff --statに変更が出ているなら、Codexが途中まで変更して失敗しています。次のworkerをそのまま投入すると、前回の半壊した変更の上に上書きが入ります。git stash -uで退避してからtask-fileに「前回non-zeroで終了。前回の変更は退避済みです。原因特定から始めてください」を追記して再投入します。

6. Remaining Risksの「None」を禁止する

task-fileにRemaining Risksには必ず1件以上の懸念事項を書くこと。Noneは禁止。と入れます。Noneが連続しているhandoff-fileは、Codexがリスク記述を省略しているサインです。懸念事項が書かれ続けることで、後続のworkerやレビュアーが注目すべきポイントを引き継げます。

7. handoff-fileのファイル名に日時とタスクIDを入れる

handoff-20260824-api-fix.mdのように命名すると、複数のworker結果を時系列で追えます。ls -lt handoffs/で最新の完了を確認でき、grep -rl "State: failed" status/と組み合わせると失敗したタスクの一覧が取れます。ファイル名が意味を持つだけで、後処理のスクリプトが格段に書きやすくなります。

8. status-fileのポーリングをSlack通知に繋げる

while true; do
  if grep -q "State: completed\|State: failed" "$STATUS_FILE"; then
    state=$(grep "State:" "$STATUS_FILE" | awk '{print $2}')
    curl -s -X POST "$SLACK_WEBHOOK" \
      -H "Content-Type: application/json" \
      -d "{\"text\": \"Worker ${state}: $(basename $STATUS_FILE)\"}"
    break
  fi
  sleep 15
done

深夜cronでCodexワーカーを走らせておき、完了または失敗をSlackで受け取ります。朝起きたらhandoff-fileを読むだけで夜間バッチの結果が分かります。実際にこの構成で「寝ている間に3タスクが完了、1つがfailed」を受け取ったことが週2〜3回あります。

9. Codexに書かせる情報とシェルで記録する情報を設計段階で分ける

  • Codexが書く: Summary / Files Changed / Validation / Remaining Risks(タスクの内容)
  • シェルが書く: State / timestamp / Branch / Worktree / git status --short(実行時の実測値)

この分離がpromptのDo not write handoff or status files yourself; the launcher manages those artifacts.という一文に凝縮されています。境界を崩すと、Codexの推測値(「たぶんmainブランチ」)とシェルの実測値(git rev-parse --abbrev-ref HEADの結果)が混在して突き合わせ検証が崩れます。

10. promptの変更をスクリプト本体のgit diffとして管理する

スクリプト内のcat > "$prompt_file" <<EOF ... EOFはスクリプト本体に埋め込まれています。promptのルールを変更するたびにgit diff scripts/orchestrate-codex-worker.shにdiffが出ます。これはむしろ利点で、git log scripts/orchestrate-codex-worker.shでpromptの変更履歴が追えます。「なぜRemainingRisksのNone禁止ルールを追加したか」をcommit messageに書いておくと、数ヶ月後にスクリプトを読んだときに意図が分かります。

11. stderrキャプチャを後から追加するための準備をしておく

現在のスクリプトはstderrをキャプチャしていません。失敗原因が端末に出た瞬間に消えます。codex exec ... 2>"$error_file"でstderrを別ファイルに逃がし、失敗時にhandoff-fileへcat "$error_file"する行を追加するだけで解消します。スクリプトが安定した後でこの改修を入れると、失敗調査の時間が大幅に短縮されます。

12. task-fileを使い捨てにせずテンプレート化する

同タイプの作業(「新しいAPIエンドポイントにバリデーションを追加する」など)を繰り返すなら、task-fileをテンプレート化します。tasks/templates/add-validation.mdを作っておき、呼び出し前にsedで対象ファイル名だけ差し替えます。Codexへの指示が一貫するので、handoff-fileの構造も安定し、grepや後処理スクリプトが壊れにくくなります。


まとめ

Codexへの作業委譲が崩れる原因は、ほぼ決まっています。「完了した」という出力を信じてその後の突き合わせを省く、これだけです。orchestrate-codex-worker.shが108行でやっていることは、この省略を構造的に許さない設計です。

set -euo pipefailでエラーを隠さない。task/handoff/status-fileの3役割を分離して関心を切り分ける。ブランチ名・worktreeパス・git status——実測が必要な値はシェルが書きCodexには書かせない。4セクション強制でCodexの出力に形式を要求する。--color neverでANSIコードの混入を入口で防ぐ。trap cleanup EXITで一時ファイルをどのパスで終了しても回収する。

それぞれの設計判断は単体では地味です。組み合わさることで、「Codexが言ったことが実際にやられているかどうか」を30秒で確認できるループが成立します。

私が複数のプロダクトを並行させながら月商120万を維持できているのは、確認を手動でやらなくて済む仕組みがあるからです。1タスクあたりの確認コストが30秒以下に収まるから、30タスクを平行させても1日の確認作業が15分で終わります。この差が積み上がって稼働の差になります。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。

📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?