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?

⚡ 10タスク並列委譲 — orchestrate-codex-worker.shで順番待ちを消す

0
Posted at

月商0円から120万円になった経緯より、今の自分が何に時間を使っているかの方がよほど面白い話です。答えは「何も待っていない」です。

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

個人開発の量産フェーズで最初に効いてくる壁は、才能でも資金でもなく「待ち時間の積み上げ」です。

たとえばiOSアプリの画像生成バッチを考えます。モンスター画像を10枚作りたいとき、もっとも素直な実装はこうなります。

for task in task1 task2 task3 ... task10; do
  codex exec "画像生成: $task"
done

Codexが1枚あたり平均90秒かかるとすれば、10枚で900秒=15分です。この15分のあいだ、CPUはほぼ遊んでいます。Codexのworkerが動いている間、次のタスクはキューの外で手を止めて待っているだけです。

もう一つの問題は、失敗したときの可視性です。ループで順番に流すと「10件目が終わってから1件目の失敗に気づく」という状況が起きます。エラーログはあっても、どのタスクが今どの状態にあるかをリアルタイムで把握する手がかりがありません。

これを解決するのが orchestrate-codex-worker.sh の設計思想です。1スクリプト=1ワーカー。並列化は呼び出し側に任せる。状態はファイルで外部化する。

ワーカーが単機能に徹することで、親スクリプト(article-daily-stock.sh)は好きな数だけワーカーをバックグラウンド起動できます。しかも各ワーカーは自分の状態を status-file に書き続けるので、親は wait で合流する前でも各ジョブの進行状況を読み取れます。

待ち時間が消えるメカニズム

10タスクを並列で走らせると、全体の所要時間は「最も遅いワーカーの処理時間」+「起動オーバーヘッド」に収束します。90秒のタスクを10並列にすれば、合計15分が実質90秒台になります。

ただしCodexはAPIコール中に内部でLLMを呼ぶので、並列数を上げすぎるとレートリミットに当たります。実運用で使っている上限はプロジェクト規模によりますが、毎朝の記事生成バッチでは launchd が8:00と10:35の2回に分けてジョブを流すことで、1回あたりの並列負荷を平準化しています。

ワーカーが「群れ」として動く前提

スクリプト内のシステムプロンプトに次の一行があります。

You are one worker in an ECC tmux/worktree swarm.

これは単なる修辞ではありません。各ワーカーは独立したgit worktreeの中で動くことが前提になっています。codex exec-C "$(pwd)" を渡すのがその実装です。ワーカーは自分のworktreeの外に触れません。プロンプトにも明記されています。

- Work only in the current git worktree.
- Do not touch sibling worktrees or the parent repo checkout.

これにより、10ワーカーが同時に異なるブランチ・異なるworktreeで動いても、互いに衝突しません。並列化の前提条件が、コードレベルで強制されています。

「状態がファイルにある」ことの価値

監視・デバッグ・通知のすべてがファイルポーリングで完結します。status-filewatch cat するだけで全ワーカーの生死確認ができます。handoff-file には完了後の成果物(Summary・Files Changed・Validation・Remaining Risks)が構造化テキストで残るので、後段のスクリプトがそれをパースして集約レポートを作ることも、Slackに流すことも、Obsidianのノートに書き込むこともできます。外部依存なし、データベースなし、シェルスクリプトだけで完結する設計です。

全体の流れ

起動チェーン(launchd → ワーカー群)

launchd (08:00 / 10:35)
  │
  └─► claude-quota-guard.py --job com.shun.article-daily
          │  クォータOK?
          ↓
      run-and-notify.sh zenn "Zenn記事ストック生成"
          │  Discord通知(開始)+終了後に結果通知
          ↓
      article-daily-stock.sh apply
          ├─ orchestrate-codex-worker.sh task_01.md handoff_01.md status_01.md &
          ├─ orchestrate-codex-worker.sh task_02.md handoff_02.md status_02.md &
          ├─ orchestrate-codex-worker.sh task_03.md handoff_03.md status_03.md &
          │   …(N並列)
          └─ wait → handoff_*.md を集約 → レポート生成

com.shun.article-daily.plistStartCalendarInterval は2エントリあります。

<key>StartCalendarInterval</key>
<array>
  <dict>
    <key>Hour</key><integer>8</integer>
    <key>Minute</key><integer>0</integer>
  </dict>
  <dict>
    <key>Hour</key><integer>10</integer>
    <key>Minute</key><integer>35</integer>
  </dict>
</array>

8:00が朝イチのメインバッチ、10:35が差分補充用のサブバッチです。plistは LowPriorityIO: trueNice: 10 で動くので、作業中のMacに負荷をかけません。ProcessType: Background の指定も入っており、macOSのスケジューラが電力効率のよいタイミングで走らせます。

起動チェーンの先頭に claude-quota-guard.py が挟まっているのは、Claude APIのクォータ残量を事前チェックして残量不足なら即中断するためです。バッチを走らせてから「途中でクォータ切れ」という事態を防いでいます。

ワーカー1本の中身

orchestrate-codex-worker.sh <task-file> <handoff-file> <status-file>
  │
  ├─ 引数チェック(3引数でなければ即exit)
  ├─ write_status "running"  → status-file に書き込み
  │
  ├─ mktemp prompt_file  ← システムプロンプト構築
  ├─ mktemp output_file  ← Codex出力の受け口
  │
  ├─ codex exec -p yolo -m gpt-5.4 --color never \
  │       -C "$(pwd)" -o output_file - < prompt_file
  │
  ├─ 成功時 ─────────────────────────────────────
  │   handoff-file に書き込み:
  │     # Handoff
  │     - Completed: <ISO8601タイムスタンプ>
  │     - Branch: `<ブランチ名>`
  │     - Worktree: `<絶対パス>`
  │     <output_file の内容>
  │     ## Git Status
  │     <git status --short>
  │   write_status "completed"
  │
  └─ 失敗時 ─────────────────────────────────────
      handoff-file に失敗サマリを書き込み
      write_status "failed"
      exit 1

引数は3つ固定です。 スクリプト冒頭に [[ $# -ne 3 ]] のガードがあり、少なくても多くても即エラーです。この単純な制約が、ラッパーシェルの実装を楽にしています。

codex exec の呼び出し形式

実際のコードがここです。

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

フラグを1つずつ確認します。

-p yolo — プロンプトモードを yolo にします。確認ダイアログを出さず、ファイル操作・コマンド実行をすべて自動承認します。無人バッチの必須フラグです。

-m gpt-5.4 — モデルをGPT-5.4に固定しています。記事下書きや画像生成プロンプトの組み立てといった「量産向け」のタスクにはこのモデルで十分で、Claude APIのクォータを温存できます。

--color never — ANSIエスケープシーケンスを出力しません。ログファイルやhandoff-fileをあとでテキスト処理するとき、カラーコードがノイズになるのを防ぎます。

-C "$(pwd)" — ワーカーの作業ディレクトリを明示します。各ワーカーは呼び出し元のworktreeで起動されるので、$(pwd) は自動的にそのworktreeのパスになります。

-o "$output_file" — Codexの最終出力をファイルに書き出します。標準出力と分離することで、スクリプトの制御フロー用のechoとCodex出力が混在しません。

- < "$prompt_file" — プロンプトをstdinから渡します。ファイルに書いてリダイレクトする方式なので、シェルのクォーティング問題を避けられます。複数行・特殊文字入りのプロンプトでも安全です。

ワーカーに渡るシステムプロンプト

prompt_file はスクリプト内でheredocとして構築されます。

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

注目すべきは「handoff・status-fileは自分で書くな」という明示的な禁止です。出力ファイルの管理は orchestrate-codex-worker.sh が一手に引き受けます。Codexワーカーは純粋にタスクの実行だけに集中する設計です。

また「Summary / Files Changed / Validation / Remaining Risks」の4セクション構造を要求しています。これは後段の集約スクリプトがhandoff-fileをパースするときのフォーマット契約です。ワーカーが何を出力するかが事前に決まっているので、集約側はセクションヘッダで分割するだけでよく、LLMの自由記述を解釈するコストがかかりません。

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
}

write_status は3回呼ばれます。タスクファイルが読めない場合に "failed"、実行開始時に "running"、そして成功・失敗それぞれの終了時に "completed""failed" です。

timestamp()date -u +"%Y-%m-%dT%H:%M:%SZ" でUTCのISO8601を返します。複数ワーカーのログを突き合わせるときにタイムゾーンの混在を防ぐためにUTC固定にしています。

git rev-parse --abbrev-ref HEAD でブランチ名、$(pwd) でworktreeパスを埋め込むので、status-fileを読むだけで「どのワーカーがどのブランチのどのworktreeで動いているか」が即座に分かります。N並列で走っているときの識別子として機能します。

クリーンアップの実装

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

trap cleanup EXIT を使うことで、正常終了・エラー終了・シグナル割り込みのいずれでも一時ファイルが残りません。10並列で走れば10組の一時ファイルが生まれるので、クリーンアップの徹底は /tmp の肥大化防止に直結します。

set -euo pipefail もスクリプト冒頭に入っています。-e でコマンド失敗時即終了、-u で未定義変数参照をエラー、-o pipefail でパイプライン中間のエラーを伝搬します。無人バッチで「エラーを握りつぶして続行」という挙動はバグの温床になるので、この3フラグは省けません。

実装の詳細

ディレクトリを自動生成する1行

スクリプト本体の処理が始まる前、引数チェックの直後にこの行があります。

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

見落としやすいですが、これがないとスクリプトは動きません。cat > "$handoff_file" は対象ファイルの親ディレクトリが存在しないと即座に失敗します。呼び出し元(article-daily-stock.sh)がhandoff-fileとstatus-fileのパスを組み立てて渡してくるので、ディレクトリが事前に存在する保証はワーカー側にはありません。

-p フラグは「すでにあれば何もしない、なければ作る、中間パスがなければ再帰的に作る」という冪等な操作です。10並列で走っているワーカーが同じディレクトリを mkdir -p しても競合しません。POSIXレベルでディレクトリ作成は原子的ではありませんが、-p は「存在していても成功する」ので複数プロセスが同時に実行しても問題ありません。

$(dirname "$handoff_file")$(dirname "$status_file") を同じ mkdir -p に並べているのは、2回呼ぶより1回で済ませる方が明快だからです。ファイルシステムの状態を変える操作は、コードに登場する回数が少ないほど追跡しやすくなります。

タスクファイルの先読みチェック

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

set -euo pipefail が入っているので cat "$task_file" が失敗してもスクリプトは止まります。それでもこのガードを先に置いている理由は、失敗の理由をhandoff-fileに残せる唯一のタイミングだからです。

codex exec を呼んだ後にファイルが読めないと判明しても、その時点では codex exec の終了コードで失敗が分かるだけで、なぜ失敗したのかを記録する場所がありません。先に読み取り可能性を確認しておくことで、「タスクファイルが壊れていた」「パスが間違っていた」といった原因をhandoff-fileに明示できます。並列で10件走っているときに1件だけ failed になった場合、handoff-fileを開けば即座に「task_03.md が読めなかった」と分かります。デバッグの往復が1ステップ減ります。

[[ ! -r "$task_file" ]]-r(readable)を使っています。ファイルが存在しても権限がなければ同じ扱いです。-f で存在チェックするより正確です。

handoff-fileの最終行がgit status --shortである理由

成功時のhandoff-fileはこう終わります。

  cat "$output_file"
  echo
  echo "## Git Status"
  echo
  git status --short
} > "$handoff_file"

git status --short を末尾に付けているのは、Codexが「やった」と言っても実際にファイルを変更したかどうかを確認するためです。Codexの最終出力(output_fileの内容)には「Files Changed」セクションがありますが、それはあくまでLLMの自己申告です。git status --short は嘘をつきません。

後段の集約スクリプトはこの ## Git Status セクションを読んで「実際に変更があったか・なかったか」を判定できます。出力が空なら「タスクは完了したがファイル変更なし」、M src/main.swift のような行があれば「実際に手が入った」と機械的に判断できます。LLMの出力をセカンドオピニオンなしで信頼しない、という設計の表れです。

plistのPATH構築

<key>EnvironmentVariables</key>
<dict>
  <key>PATH</key>
  <string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>
</dict>

launcdは ~/.zshrc~/.zprofile も読みません。ログインシェルを経由しないので、nvm の初期化も行われません。つまりlaunchdから起動したプロセスのデフォルトPATHには /usr/bin:/bin:/usr/sbin:/sbin しか含まれておらず、~/.nvm/versions/node/v24.13.0/bin/codex は一切見えません。

EnvironmentVariables でPATHを明示的に書くのはそのためです。nvm管理下のNodeバイナリパスを先頭に置き、Homebrewのパスを続け、標準パスで閉じる。この順番には意味があります。

先頭に置くほど優先されます。codex がnvm配下にあるので、それを先頭にしておかないとシステム標準の古いNodeが見つかってしまいます。/opt/homebrew/bin をnvmの後に置いているのも同じ理由です。Homebrewでインストールしたツールをnvmより優先させるとバージョン管理が崩れます。

RunAtLoad: falseの意味

<key>RunAtLoad</key>
<false/>

true にするとMacが起動するたびにジョブが走ります。記事生成バッチはAPIコールを伴うので、Mac起動のたびにAPIが消費されます。StartCalendarInterval で時刻指定をしているのに RunAtLoad: true を加えると「起動時1回+8:00・10:35の計3回」になる可能性があります(macOSのバージョンと起動タイミングによって挙動が変わります)。false が意図した設定です。

ログは StandardOutPathStandardErrorPath でそれぞれ ~/.claude/logs/article-daily.out.log~/.claude/logs/article-daily.err.log に分かれます。stdoutは正常系のステータスメッセージ、stderrはエラーと例外的な出力で分かれているので、tail -f ~/.claude/logs/article-daily.err.log だけ監視していれば問題の有無を確認できます。


私が詰まった話

1. launcdから起動したら「codex: command not found」で全滅した

最初にplistを書いてlaunchdに登録したとき、ワーカーは全件 failed で終わりました。エラーログには codex: command not found とだけ書いてありました。

ターミナルから手動で ~/.claude/scripts/article-daily-stock.sh apply を叩くと普通に動きます。launchdから起動すると動かない。この差分がすぐには分かりませんでした。

原因はPATHです。ターミナルでは ~/.zprofile が読まれてnvmが初期化され、codex が通っています。launchdは完全に別のプロセス空間で動くので、その初期化は一切起きません。

確認方法は単純で、launchdが実際に何のPATHを持っているかをログに吐かせることです。

/usr/bin/env > ~/.claude/logs/env-dump.txt

こんな1行をplistのProgramArgumentsに仕込んで走らせると、launchdの環境変数が丸ごとファイルに残ります。実際に確認したら PATH=/usr/bin:/bin:/usr/sbin:/sbin の4パスしか入っていませんでした。

修正はplistへ EnvironmentVariables ブロックを追加するだけです。ただしnvmのバージョンをハードコードする必要があります。v24.13.0 の部分です。nvmでNodeバージョンを上げたらplistも更新しなければなりません。面倒ですが、launcdが動的にnvmを初期化できない以上、この手間は回避できません。

2. --color never を忘れたら後段の集約が壊れた

-m gpt-5.4 は指定したのに --color never を省いたことがありました。コンソールで見るとCodexの出力は綺麗に色付きで表示されていて、一見正常に動いているように見えました。

問題が出たのは集約スクリプトが動いたときです。handoff-fileをパースして「Summary」セクションを切り出すスクリプトが、文字化けした謎の文字列を返してきました。

原因はANSIエスケープシーケンスです。\e[32m(緑色)や \e[0m(リセット)がhandoff-fileの中身に混入していました。## Summary というヘッダを検索するとき、実際のファイルには \e[1m## Summary\e[0m のような形で入っているので、単純な文字列マッチが効かなくなります。

cat handoff_01.md してターミナルで見るとカラー表示されるので問題が見えません。cat -A handoff_01.mdod -c handoff_01.md | head でバイナリ確認して初めて ^[[32m のような制御文字が見えました。

--color never の1フラグで完全に解決します。出力がファイルに入る以上、ANSIコードは百害あって一利なしです。以来、codex exec を書くときには -o <file>--color never はセットで書くルールにしています。

3. set -euo pipefail が想定外の場所で刺さった

set -euo pipefail を入れているので、スクリプト内の任意のコマンドが0以外で終了すると即座にスクリプト全体が終了します。これ自体は意図した挙動ですが、想定していなかった場所で刺さることがありました。

write_status 関数の中の git rev-parse --abbrev-ref HEAD です。通常のworktreeで呼べば問題ありませんが、テスト目的でgitリポジトリ外のディレクトリから呼んだとき、git rev-parse がエラーで終了しました。set -e がそれを拾って write_status が途中で抜け、status-fileが空のまま cat で失敗した、という連鎖が起きました。

症状としては「status-fileが0バイトのまま」「ワーカーが何のログも残さず終了」というものでした。エラーログには fatal: not a git repositorygit rev-parse のエラーが残っていましたが、status-fileを見に行くスクリプトが「ファイルが空」と判断して別のエラーを出したので、根本原因の特定に時間がかかりました。

本番では常にgit worktreeの中でワーカーが動くので実害はありませんが、動作確認を本番に近い環境でやらないと見つからないクラスのバグです。ローカルの適当なディレクトリでテストすると、前提条件が崩れてまったく別の壊れ方をします。以来、スクリプトの動作確認は必ず git worktree add で作った一時worktreeの中で行っています。

4. RunAtLoad: true にしてMac起動時に課金が走った

試験的に RunAtLoad: true にしてplistを更新したことがあります。目的は「launchdへの登録直後に即動作確認したい」というものでした。launchctl load した瞬間にジョブが走るので動作確認が速くなる、という算段です。

問題は、その後 RunAtLoad: false に戻し忘れたことです。翌朝Macを再起動すると、起動直後に記事生成バッチが走り、さらに8:00にも走りました。1日に3回走ったことになります(起動時・8:00・10:35)。API消費が跳ね上がり、Discordに通知が3件来て初めて気づきました。

launchctl unload して RunAtLoadfalse に直して launchctl load し直せば済む話ですが、気づくまでに2日かかりました(Discordの通知が連続3件来た日に「おかしい」と思った)。

今は「RunAtLoad の値を変えるときは同じコミットに必ずコメントを入れる」というルールで運用しています。意図的な true ならそれを明示する。忘れたまま放置すると後から見ても判断できなくなるからです。

5. heredocの中でバックスラッシュが消えた

システムプロンプトを構築するheredoc部分で、初期バージョンのスクリプトではWorktreeパスをバッククォートで囲む表記が崩れていました。

cat > "$prompt_file" <<EOF
- Worktree: `$(pwd)`
EOF

これを実行すると、プロンプト中のバッククォートはシェルに解釈されます。$(pwd) はheredoc内で変数展開されますが、バッククォートもコマンド置換として処理されるので二重展開のような挙動になりました。

意図していたのは「プロンプトの中にWorktreeのパスをバッククォートで囲んで埋め込む」ことです。Markdownのコードスパン表記として \/path/to/worktree`` を出力させたかったのに、バッククォートが消えてただのパス文字列だけが残りました。

修正は \`` でエスケープするか、heredocのデリミタをクォートして変数展開をそもそも止めるかの2択です。スクリプトが採用したのは前者で、部分的にバックスラッシュを入れる方法です。現在のコードでは write_status内のstatusファイルへの書き込みで`` 表記が使われています。

- Worktree: \`$(pwd)\`

これで $(pwd) はシェルに展開されてパスになり、バッククォートはそのまま出力に残ります。heredocの中でのエスケープはシェルのクォーティングルールと異なる部分があるので、一度詰まると原因が分かりにくいバグです。echo に渡して出力を確認する癖をつけるのが早道です。

つまずきポイント

前段でナラティブ形式に取り上げた5件以外にも、実運用で踏んだ穴は多数あります。箇条書きで網羅しておきます。

  • 並列数を上げすぎてレートリミットに全滅した — 10タスクを一斉に起動したところ、Codex内部のAPIレートリミットで複数ワーカーがほぼ同時に非ゼロ終了し、set -euo pipefail が全員を即停止させました。現在は StartCalendarInterval を8:00と10:35の2エントリに分けることで1回あたりの同時起動数を減らし、API負荷を平準化しています。並列数の上限はAPIプランとモデルによって変わるので、最初は3〜5から始めて様子を見るのが安全側です。

  • wait が失敗ワーカーのexit codeを吸収していた — 親スクリプトが複数の $!(バックグラウンドPID)をまとめて wait すると、Bashは最後に終了した子プロセスのexit codeだけを返します。9件成功・1件失敗でも wait0 を返し、バッチ全体が「成功」扱いになることがあります。正しく検知するには wait $pid; result=$? と個別に拾うか、status-fileの State: failed を集約後にgrepする方式が確実です。

  • plistを更新しても launchctl unload/load を忘れた — plistファイルを直接編集して保存しても、launchdはinotifyのような変更監視をしません。launchctl unload ~/Library/LaunchAgents/com.shun.article-daily.plist && launchctl load ~/Library/LaunchAgents/com.shun.article-daily.plist を実行して初めて新しい設定が反映されます。「直したのに挙動が変わらない」という現象の9割はこれです。

  • kill -9 で止めたら一時ファイルが /tmp に残り続けたtrap cleanup EXIT はSIGTERMやSIGINTをキャッチしますが、SIGKILLはカーネルがプロセスを直接消すためtrapが一切動きません。強制終了するときは kill -TERM <pid> を使うのが原則です。どうしても残骸が溜まる場合は、find /tmp -name 'tmp.*' -mmin +60 -delete を別のlaunchdジョブで定期実行するのが現実解です。

  • タスクファイルに \r(Windows改行)が混在していた — heredocで $(cat "$task_file") を展開するとき、ファイル末尾に \r が入っているとCodexに渡るプロンプトが1行ずつ \r 終端になります。見た目のgrepでは気づかず、Codexの応答が「指示の解釈がおかしい」という形でだけ現れました。file task_01.md でCRLFを確認し、sed -i '' 's/\r//' task_01.md で事前に変換してから渡すのが安全です。

  • git worktree add 時にブランチを省いてdetached HEADになったgit worktree add /tmp/wt-01 とブランチ指定なしで追加すると、worktreeがdetached HEAD状態になります。この状態で write_status 内の git rev-parse --abbrev-ref HEAD を呼ぶと HEAD という文字列が返ります。status-fileの Branch: HEAD を集約スクリプトが正常なブランチ名として処理するため、集約結果がおかしくなります。git worktree add -b worker-01 /tmp/wt-01 と必ずブランチを切るのが正解です。

  • output_fileが数十MBに膨らんでディスクを圧迫した — Codexが長文タスクで大量に出力すると mktemp で作った $output_file が数十MBになることがあります。10並列が同時にこの状態になると /tmp は一気に数百MB消費します。trap cleanup EXIT で消えるので通常は問題になりませんが、SIGKILLで止めた直後に次のバッチが走ると残骸と合算されます。タスクファイルは「1件=1作業単位」に細かく切って、1タスクあたりの出力量を制御するのが根本対策です。

  • article-daily.err.log が無制限に肥大化した — plistの StandardErrorPath はログを自動ローテーションしません。毎朝2回走るバッチのstderrを垂れ流すと、1ヶ月で数百MBになります。簡単な対処は article-daily-stock.sh の先頭で > ~/.claude/logs/article-daily.err.log と切り詰める1行を入れることです。永続的なログは残りませんが、「今日のエラーだけ確認する」運用なら十分です。長期保存が必要なら newsyslog の設定に追加します。

  • plistの Label 値とファイル名を不一致にしてlaunchdが迷子になったLabelcom.shun.article-daily と書いたのに、ファイル名を article-daily.plist にしてしまいました。launchctl list で見つからず、launchctl unload に正しいLabelを渡しても「not found」を返されます。macOSの慣習として <Label>.plist をファイル名にするルールに従えば、この問題は一度で終わりです。

  • ネットワーク切断でcodex execがハングしたまま戻ってこなかった — APIへのHTTP接続が途中で切断されると、codex exec がタイムアウト応答なしでブロックしたまま残ることがあります。親スクリプトの wait が永久に返らない状態になりました。回避策は呼び出し元で timeout 600 codex exec ... のようにタイムアウトを外側でかけることです。600秒を超えるタスクはそもそも細かく分割する設計にした方が、ネットワーク切断リスクも下がります。

  • 同じworktreeパスを複数のワーカーに渡してしまった — テスト時に -C "$(pwd)" が全ワーカーで同じパスを指してしまい、10ワーカーが同じworktreeの同じファイルを同時に書き換えました。git status --short の結果が混在し、どのワーカーの変更かを事後に追跡できなくなりました。1ワーカー=1worktreeの原則は、プロンプトへの明記(Work only in the current git worktree)だけでなく、呼び出し元が物理的に異なるworktreeパスを -C に渡す構造で強制する必要があります。


ベストプラクティス

実運用で積み上げた判断を14点にまとめます。コードの根拠も合わせて示します。

1. set -euo pipefail は絶対に省かない

スクリプト1行目にあるこの設定が無人バッチの生命線です。-e でどこかのコマンドが非ゼロで終わった瞬間に止まり、-u で変数名のタイポが即座に発覚し、-o pipefail でパイプライン途中のエラーが呑み込まれるのを防ぎます。10並列で走るバッチがエラーを黙って続行すると、診断コストが件数倍に膨らみます。

2. trap cleanup EXIT は mktemp の直後に置く

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

mktemp で作ったファイルはシグナル・エラー・正常終了のすべてのルートで消えなければなりません。trap を後ろに書くと「trapが登録される前にエラー終了した場合」に残骸が残ります。mktemp直後に置くのが鉄則です。

3. --color never-o <file> はセットで書く

codex exec の出力をファイルに入れる以上、ANSIエスケープシーケンスは不要なノイズです。--color never を忘れると \e[1m## Summary\e[0m のような文字列がhandoff-fileに混入し、後段のセクション分割が全滅します。-o "$output_file" との組み合わせをひとかたまりの慣用句として扱い、片方だけ書くことがないようにします。

4. write_status は「開始・成功・失敗」の3点のみ

ワーカーの状態を外部から読めるようにするには、書き込みタイミングを絞ることが大事です。このスクリプトは running(codex exec 直前)・completed(成功終了)・failed(エラー終了)の3パターンだけです。途中経過を細かく書くと、読み側が「書き込み中のファイル」を読む競合が発生します。

5. タスクファイルの読み取り確認を codex exec より前に置く

[[ ! -r "$task_file" ]] のチェックが codex exec 呼び出しより前にある理由は、「失敗理由をhandoff-fileに書けるのがこのタイミングだけ」だからです。後で判定しても、なぜ失敗したかが記録に残りません。10並列の中で1件だけ failed になったとき、handoff-fileを開けば「タスクファイルが読めなかった」と即座に分かる状態を維持します。

6. mkdir -p の1行を書き込みより必ず前に置く

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

cat > "$handoff_file" は親ディレクトリが存在しなければ即失敗します。呼び出し元がhandoff-file・status-fileのパスを組み立てて渡すので、ディレクトリが事前に存在する保証がワーカー側にはありません。-p は「あれば何もしない」冪等操作なので10並列で同時実行しても安全です。

7. launchdのPATHは EnvironmentVariables に全量書く

launchdは ~/.zshrc~/.zprofile も読みません。素のlaunchd環境のPATHは /usr/bin:/bin:/usr/sbin:/sbin の4パスだけです。nvm配下のNodeバイナリを使うなら、plistの EnvironmentVariables に明示するしか方法がありません。先頭に置いたパスが優先されるので、nvm → Homebrew → 標準パスの順番にすることでバージョン管理の優先順位をそのまま表現できます。

8. RunAtLoad: false を基本とし、変更時は理由をコミットメッセージに残す

RunAtLoad: true はlaunchdへの登録直後に即動作確認できる便利さがありますが、本番ではMac起動時のAPI消費が増えます。StartCalendarInterval と組み合わせると「起動時+定刻×2」の最大3回実行になり得ます。false が既定でよく、true に変更する場合はコミットメッセージに「テスト用・戻すこと」と書くことで、忘れたまま放置するリスクを下げます。

9. LLMの自己申告を git status --short でセカンドオピニオンする

handoff-fileの末尾に git status --short の出力が入る設計は、Codexが「Files Changed」に書いた内容を検証するためです。出力が空なら「何も変わっていない」、M src/main.swift なら「実際に手が入った」と機械的に判定できます。LLMの出力を第二のAIに解釈させるより、gitの状態を直接読む方が速く・安く・嘘をつきません。

10. 出力の4セクション構造をプロンプトで要求する

1. Summary
2. Files Changed
3. Validation
4. Remaining Risks

この構造をprompt_fileのheredocに明記するのは、集約スクリプトとのフォーマット契約です。Codexが自由記述で返してきた内容をLLMでさらに解釈するより、固定のセクションヘッダで機械的に分割する方がトークンもコストも節約できます。「どこに何が書いてあるか」がプロンプトの段階で決まっていれば、後段の実装は単純なテキスト処理で済みます。

11. 動作確認は必ず git worktree add した一時worktreeの中で行う

set -euo pipefail のスクリプトを本番環境と異なる前提条件(gitリポジトリ外・detached HEAD・変数未定義)で動かすと、想定外の場所で止まってデバッグが難しくなります。git worktree add -b test-worker /tmp/wt-test で一時worktreeを作り、その中からワーカーを呼ぶのが最も本番に近い確認方法です。

12. stderrとstdoutのログファイルを分けて、err.logだけ監視する

plistの StandardOutPathStandardErrorPath を別ファイルにすると、tail -f ~/.claude/logs/article-daily.err.log だけ見ていれば問題に即座に気づけます。両方を同じファイルに書くと正常ログとエラーが混ざり、異常の発見が遅れます。stderrが静かなら今日のバッチは正常だと判断できる状態にしておくことが、無人運用の精神的コストを下げます。

13. nvmのバージョン更新時はplistの EnvironmentVariables もセットで直す

plistに書いた ~/.nvm/versions/node/v24.13.0/bin というパスは、nvmでNodeバージョンを上げた瞬間に無効になります。翌朝のlaunchdジョブが codex: command not found で全滅するまで気づかないケースが一番危険です。nvmのバージョンアップ手順にplist更新を組み込むか、~/.nvm/alias/default シンボリックリンクを活用してバージョン固定への依存を構造的に減らすかが対策になります。

14. -C "$(pwd)" で作業ディレクトリを必ず明示する

codex exec-C を渡さないと、呼び出し元のカレントディレクトリで動きます。並列ワーカーが全員同じCWDで動いたり、親スクリプトがcdしていたりすると、意図しないworktreeで実行されます。-C "$(pwd)" を省かず書くことで、「このワーカーはこのworktreeで動く」という事実をコードで強制できます。


まとめ

orchestrate-codex-worker.sh の設計思想を一言で言えば「ワーカーを単機能にして、状態をファイルに外出しする」です。

10タスクを順番に流せば合計900秒かかる作業が、N並列にすれば最も遅いタスク1件分+起動オーバーヘッドに収束します。この並列化に必要なのはKubernetesでもDockerでもなく、&(バックグラウンド起動)と wait(同期待ち)と cat > "$status_file"(状態外出し)の3つです。108行のシェルスクリプトにその全部が収まっています。

com.shun.article-daily.plist が毎朝8:00と10:35にチェーンを起動し、クォータガード・Discord通知・N並列ワーカー・結果集約の一連が人間の介在なしに完走します。私がMacを開く前に、記事の下書きはすでにできています。月商120万の実態は、この「自分が寝ている間に動く仕組み」をいくつ積めたかの合計です。才能でも量でもなく、待ち時間をゼロに近づける設計の積み重ねが、結果として収益に変わっています。


仕組みの全体像・月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?