GitHub Issueに仕事を書いておくと、Mac mini上のOrchestratorがIssueを拾い、Codexがコードを実装してテストを実行します。その後はOrchestratorが変更内容を検証してPRを作り、別のCodexがレビューします。レビューで問題が見つかれば同じPR上でreworkし、最後のmergeだけは人間が行う仕組みにしました。

この処理を動かしているのは、以前の記事でUbuntu 24.04を入れてヘッドレスの開発サーバにした2012年のMac miniです。systemdの起動が starting のまま終わらなかった問題を直したのも、こうした常駐処理を安定して動かしたかったためでした。

今回作った ai-orchestrator の大まかな処理は次のようになっています。

GitHub Issue

agent:ready

Mac mini上のOrchestratorが選択

Issue専用のbranch / worktreeを準備

Codexが実装してテスト

Orchestratorが変更内容を検証

commit / push / PR作成

agent:review

別のCodexがread-onlyでレビュー

  ├─ approve → human:review → 人間が確認してmerge

  └─ rework → agent:rework

              Codexが修正

              同じPRを再レビュー

Codex CLIを起動する処理そのものより、常時動かすための状態管理、Git操作の境界、失敗時の再開、レビュー結果の扱い、停止条件の方に実装が増えました。この記事では、その中で実際に必要になった工夫を紹介します。

DBを作るのが面倒だったのでGitHubラベルで状態を持つ

ジョブの状態を専用DBへ保存する方法もありますが、DBを用意するとschema、migration、backup、復旧方法まで管理対象になります。今回の規模でそこまで増やすのは手間だったため、すでに使っているGitHub Issueを仕事の入口にし、ラベルで状態を表すことにしました。

現在使っている主なラベルは次の通りです。

agent:ready
agent:working
agent:review
agent:rework
agent:blocked
human:review

agent:ready を付けたIssueが新しい仕事です。作業を始めると agent:working、PRを作って自動レビューへ回すと agent:review、修正が必要なら agent:rework、自動処理を止める必要があれば agent:blocked、最後に人間が確認する段階で human:review になります。GitHubを開けば現在の状態を確認できるので、専用の管理画面も作っていません。

ただし、ラベルだけを実行の根拠にはしていません。 たとえばIssueに agent:rework が付いていても、人間が誤って付けた可能性や、レビュー後にPRのheadが変わっている可能性があります。reworkを実行するときは、対象のIssue、PR、branch、worktree、base SHA、head SHA、前回のレビュー結果まで確認します。

agent:rework

対象Issueは正しいか

対象PRは正しいか

base / head SHAはレビュー時と同じか

worktreeは正しいbranch / HEADか

Orchestrator自身が残したrework判定が存在するか

すべて一致した場合だけCodexを実行

ラベルは人間が確認しやすい状態表示として使い、コードを書き換えてよいかどうかはGitとGitHubの状態を組み合わせて判断します。DBを作らない代わりに、GitHubとGitを状態管理へかなり強く使う構成になりました。

CodexにはGitHub操作をさせない

実装用のCodexにはGitHub操作をさせません。Codexが担当するのは、対象リポジトリを読み、コードを書き、テストやデバッグを行い、検証結果を返すところまでです。

Codex
  リポジトリを読む
  コードを書く
  テストする
  デバッグする
  検証結果を返す

Orchestrator
  Issueの状態変更
  branch / worktree管理
  変更内容の検証
  commit
  push
  PR作成・再利用
  レビュー結果の記録

Codex側へcommitやpush、PR作成まで許可すると、GitHub上の状態遷移もLLMの判断に依存します。コードの実装には曖昧な判断が必要ですが、「このbranchをpushしてよい」「このPRを作ってよい」「このIssueをreview状態へ進めてよい」といった操作は、条件をプログラムで確認してから実行したいのでOrchestrator側へ分けました。

pushではforce pushを通常経路に入れず、想定していないremote divergenceがあれば停止します。失敗状態を自動修復するための git reset --hardgit clean も行いません。常時動く処理では、状態を推測して先へ進むより、判断できない状態で止めて作業内容を残す方を優先しています。

Issueごとにworktreeを分ける

Codexが作業する場所は普段使うcheckoutと分け、Issueごとに専用branchとGit worktreeを作ります。

branch:
  agent/issue-58

worktree:
  ~/ai/worktrees/<owner>/<repo>/issue-58/

複数リポジトリを扱うようになってからは、Issue番号だけでは衝突するためリポジトリ単位のnamespaceも入れています。repo AのIssue #10とrepo BのIssue #10は別の仕事なので、worktreeも次のように分かれます。

~/ai/worktrees/owner/repo-a/issue-10/
~/ai/worktrees/owner/repo-b/issue-10/

現在は一度に一つの仕事だけを処理しているため、この分離の主目的は並列化ではありません。Codexの実行、commit、push、PR作成のどこで失敗しても、Issue専用worktreeにその時点の作業状態を残せるようにしています。失敗時に自動で綺麗な状態へ戻すより、何が起きたのか確認できる状態を保つ方が復旧しやすいためです。

自動レビューは別のCodexをread-onlyで動かす

PRを作った後は、実装を担当したCodexとは別にレビュー用のCodexを起動します。レビュー担当に必要なのはIssueの要件、PRのdiff、関連ファイルを読み、問題を指摘することなので、コードを書き換える権限は必要ありません。

そのため、プロンプトで「変更しないでください」と指示するだけでなく、実行時のsandbox自体をread-onlyにしています。

implementation worker
  → workspaceへの書き込みを許可

reviewer
  → read-only

AIエージェントの権限制御をプロンプトだけに任せず、役割上不要な権限は実行環境でも渡さないようにしています。

レビュー結果を自由文のまま状態遷移に使わない

レビュー用Codexの最終結果は自由文ではなく、JSONの構造化データとして返させます。簡略化すると次のような形式です。

{
  "version": 1,
  "verdict": "rework",
  "summary": "修正が必要です",
  "findings": [
    {
      "severity": "major",
      "path": "orchestrator/example.py",
      "line": 123,
      "message": "この条件では再開時に重複実行されます"
    }
  ]
}

verdict は基本的に approve または rework で、結果に応じて human:review または agent:rework へ進みます。

approve → human:review
rework  → agent:rework

自由文のレビューでは「全体として問題はないが、一点だけ修正を検討してもよい」のような表現も普通に出ます。人間が読むには問題ありませんが、プログラムが次の状態を決める入力としては曖昧です。そのため状態遷移に使う値はschemaを固定し、想定外のversionやverdict、severity、壊れたJSONは拒否します。

レビュー結果はPRに残す

レビュー結果はローカルだけに保存せず、PRコメントとしてGitHubへ残します。Mac miniは再構築できる前提で使っているため、ローカルだけを正にするとOSの再インストールやディスク故障で「このPRはどこまでレビュー済みか」「何回reworkしたか」といった情報を失います。

GitHubにはIssue、PR、commit履歴が残るので、レビューコメントもそちらへ置きます。コメントには対象になったbase SHAとhead SHAを結び付けています。

review result
  base = abc123...
  head = def456...

PRへ新しいcommitが追加されてheadが変われば、古いレビュー結果は現在のコードを承認する根拠には使いません。プロセス再起動やホスト再構築の後でも、現在のPRとGit履歴を照合してどのレビューがどのコードに対するものだったかを確認できます。

reworkを最大2回にしたのは無限ループを止めるため

自動レビューで rework が返ると、その指摘をCodexへ渡して同じPR上で修正し、新しいheadをもう一度レビューします。常時運用でこれを無制限にすると、解決できないIssueでもCodexとReviewerが繰り返し起動し続けます。

Codexが修正

Reviewerがrework

Codexが修正

Reviewerがrework

Codexが修正

...

現在は自動reworkを最大2回にしています。2回という数字に一般的な根拠があるわけではなく、自分の運用で「ここから先は人間が見る」と決めた境界です。

初回実装

レビュー

rework 1回目

レビュー

rework 2回目

レビュー

まだrework

agent:blocked

agent:blocked へ移ったIssueは、それ以上Codexを自動実行しません。要件の矛盾や設計上の問題をAIが何度も修正し続けると、Codexの利用量と実行時間だけを消費する可能性があるため、常駐運用では停止条件をOrchestrator側で持たせています。

rework回数はローカルDBではなくPRの履歴から数える

reworkを最大2回にするには、現在何回目なのかをどこかで数える必要があります。ローカルcheckpointへ rework_count: 1 のように保存する方法もありますが、それではMac miniを再構築したときに回数が消えますし、正常終了後に一時checkpointを削除する運用とも相性がよくありません。

そこで、rework回数はPRに残っているOrchestrator自身のレビュー履歴とGitのcommit lineageから計算しています。

review済みheadが1つ
  → 初回レビューのみ
  → rework 0回

review済みheadが2つ
  → rework後のheadが1つ増えている
  → rework 1回

review済みheadが3つ
  → rework 2回

単にレビューコメントの個数を数えるのではなく、対象PRの同じbase lineageに属していること、現在のGit履歴と矛盾しないこと、Orchestrator自身が作成した正しい形式のレビュー結果であることも確認します。DBを増やさなかった分、GitHubとGitの履歴をdurable stateとして使うために必要になった処理です。

モデルも無制限に強いものへ上げない

Codexに使うモデルもフェーズごとに設定しています。現時点の自分の設定は次の通りです。

初回実装      gpt-5.6-luna / medium
自動レビュー  gpt-5.6-luna / medium
rework 1回目  gpt-5.6-luna / medium
rework 2回目  gpt-5.6-terra / medium

通常の実装とレビューはLunaから始め、最初のreworkでもモデルは上げません。二度目のreworkでTerraへ切り替えます。2回直してもレビューを通らないIssueは、さらに高いモデルへ自動昇格させるのではなく agent:blocked へ止め、人間が要件や設計を確認します。

常駐処理では、モデル選択もその場の判断ではなく運用ポリシーになります。高性能なモデルを常に使うより、通常経路と再試行で使うモデルを決めておく方が、コストと停止条件を管理しやすくなります。

Codexは成功したのにdeliveryから進まない問題が起きた

systemd常駐化の作業中、Codexによる実装は正常に完了したものの、その後のdelivery検証で git diff --cached --check が失敗したことがあります。原因はファイル末尾の余分な空行でした。

worker
  → 成功

checkpoint
  → worker_succeeded

delivery
  → git diff --cached --check
  → 失敗

checkpointではworkerが成功済みなので、次回の run-once はCodexをもう一度動かさずdeliveryから再開します。しかし、このエラーを直すにはファイル編集が必要です。その結果、同じ検証へ戻るだけの状態になりました。

delivery validationで失敗

次のrun-once

workerは成功済みなのでskip

同じdelivery validation

同じエラー

このケースを受けて、delivery失敗の中でも「workerへ戻せば修正できるもの」と「状態が曖昧なので人間が確認するもの」を区別する必要が出てきました。checkpointに前工程の成功だけを記録しても十分ではなく、後工程で見つかった問題をどの工程なら修正できるのかまで再開処理に含める必要があります。

新しいIssueより途中の仕事を先に片付ける

一度に一つだけ仕事を処理しているので、スケジューラがどのIssueを次に選ぶかにも優先順位があります。当初は新しい agent:ready が、すでに途中まで進んでいる agent:workingagent:reviewagent:rework より先に選ばれる可能性がありました。

ready Issueが追加され続けると、worktreeやPRまで作成済みの途中タスクが後回しになります。現在は新しい仕事を始める前に、すでに開始済みの仕事を進めるようにしています。

working
review
rework
  ↓ 優先
ready

タスクキューとしては単純なFIFOではなく、途中状態を持つタスクを完了方向へ進めることを優先する形です。

systemdではPythonを常駐させずone-shotを繰り返す

Mac mini上ではPythonの無限ループを常駐させず、systemd timerから約15分ごとに run-once を起動しています。

python3 -m orchestrator run-once

run-once はその時点で進めるべき仕事を一つ選んで処理し、完了したらプロセス自体が終了します。スケジュール、プロセス起動、journalへのログ記録、再起動後の復帰はsystemd側へ任せています。

systemd timer

run-once

1つの仕事を処理

process exit

次回timerまで待つ

長時間生き続けるscheduler loopをPython側に追加せず、もともとのone-shot処理をそのまま定期実行できる構成です。ただしtimerだけでは、systemd経由の実行中にSSHから手動で run-once を起動する競合までは防げません。そのためmutatingな処理にはOS側のsingle-instance lockを入れ、二重実行された場合は後から来た方を停止します。

複数リポジトリは仕事があるrepoだけ準備する

途中から、一つのリポジトリだけではなく、自分が所有する複数のprivate repositoryも対象にしました。すべてのrepoをMac miniへcloneして定期更新するのではなく、GitHub側で対象Issueを確認し、実行可能な仕事が見つかったrepoだけsource checkoutとworktreeを準備します。

GitHubでrepoを確認

Issue状態を確認

仕事なし → cloneしない

仕事あり

必要ならclone

Issue専用worktreeを準備

リポジトリ数が増えても、仕事がないrepoのcloneやfetchを毎回行う必要はありません。またtask identityもIssue番号だけではなく、repository identityとIssue番号の組み合わせで扱います。同じ Issue #10 が複数repoに存在しても、worktree、checkpoint、PR authority、housekeepingが混ざらないようにするためです。

human:review になったらGitHubのassigneeで自分に戻す

自動レビューが approve になった後は、Issueを human:review へ進めて自分自身をassigneeに追加します。専用の通知システムは作らず、GitHubが持っているassignmentと通知をそのまま使っています。

状態管理をラベルへ寄せたのと同じく、GitHubですでにできることはなるべくGitHubへ任せる方針です。mergeは現在も人間の操作にしており、自動レビューを通ったPRがそのままmainへ入ることはありません。

このOrchestrator自身の改善にも使っている

ai-orchestrator がある程度動くようになってからは、その改善Issueも同じ仕組みで処理しています。

ai-orchestratorのIssueを書く

ai-orchestratorがIssueを拾う

Codexがai-orchestratorを修正

PRを作る

ai-orchestratorが自動レビューする

人間がmerge

途中仕事を新しいready Issueより優先するスケジューラ改善なども、この経路で実装しました。自分自身を対象にできるようになると、実装の流れだけでなく、merge後に常駐プロセス側のコードをどう更新するかも考える必要があります。

自分自身を改善してもresident checkoutは自動で更新されない

human:review になったIssueへ自分をassignする機能を追加したとき、PRをmergeした後の次のIssueでもassigneeが付かないことがありました。GitHub上の main は更新されていましたが、Mac miniでsystemdから実行しているresident checkoutが古いcommitのままだったためです。

Orchestratorが自分を修正

PR

人間がmerge

GitHubのmainは新しい

Mac miniのresident checkoutは古い

次のrun-onceも古いコードで動く

手動でfetchしてfast-forwardすれば更新できますが、自動化するならresident checkoutがcleanか、正しいbranchにいるか、remoteとdivergeしていないかを確認する必要があります。実行中のPythonプロセス自身にcheckoutを更新させるのも境界が分かりにくくなります。

現時点では自己更新部分は改善課題として残しており、Orchestrator本体ではなくsystemdと run-once の間に小さな更新処理を置く方向で考えています。期待するrepoの main で、checkoutがcleanかつfast-forwardだけで更新できる場合に限って更新し、条件が合わなければその回の実行を止める構成です。

checkoutが期待するrepoか

mainか

cleanか

origin/mainをfetch

fast-forwardだけで更新可能か

問題なければ更新

run-onceを起動

Codexを常駐ワーカーにすると周辺の設計が増える

現在のコードでは、Codex CLIを起動する処理より、その前後を管理する処理の方がかなり大きくなっています。Issueの状態確認、repositoryとIssueを組み合わせたtask identity、worktreeとbranchの所有権確認、checkpointからの再開、commitやpush、PR作成のidempotency、自動レビュー、rework budget、housekeeping、systemd timer、single-instance lockなどが必要になりました。

Codexを人間が必要なときに起動するだけなら、これらの多くは人間がその場で判断できます。GitHub Issueを見て常時仕事を進めるワーカーにすると、どの情報を信用するか、どの操作をAIへ許可するか、失敗後にどこから再開するか、何回まで再試行するかをプログラム側で決める必要があります。

自分の構成では、コードの実装とテストはCodexへ任せ、GitHubの状態変更やdeliveryはOrchestratorが条件を確認して行います。レビューは別のread-only Codexへ分け、reworkには上限を設け、曖昧なGit状態は自動修復せず停止します。最後のmergeも人間が行います。

2012年のMac miniはいま、GitHub Issueを拾ってCodexへ仕事を渡す常時稼働マシンになりました。作ったものはCodexを呼び出すスクリプトというより、Codexを常時動かすための小さなジョブオーケストレーターです。