Symphony × GitHub Issues で PR を自動生成できるようにしてみた

こんにちは、makeshop事業本部開発部の佐藤です。

OpenAI が公開したエージェントオーケストレーター Symphony を、GitHub Issues から動かすためのアダプタを作ってみました。2026 年 5 月時点で Symphony は公式には Linear 専用 で、リポジトリ内に GitHub 連携用のコードが一部存在するものの、そのままでは動かない状態でした。実際に運用に乗せるまでに、いくつかの修正と設定の工夫が必要だったのでその記録としてまとめておきます。

Symphony とは

Symphony は OpenAI が 2026 年 4 月に公開した、コーディングエージェント向けのオーケストレーターです。一言で言うと「プロジェクト管理ボードをエージェントのコントロールプレーンに変える」ツールで、Issue のステータスが Ready になるとそれを検出して Codex セッションを自動起動し、コード変更から PR 作成までを行います。

実装としては Elixir で書かれたローカルアプリケーションで、Codex CLI をその子プロセスとしてローカルで動かします。リモートサーバや専用インフラは要らず、開発者本人のマシン上で「自分専用のエージェント常駐ワーカー」として動かす構成です。トークン消費も普段使っている自分の Codex アカウントを使用できる、ローカル完結のツールです。

OpenAI の公式ブログでは、Symphony を導入した社内チームでマージされた PR の数が 3 週間で 6 倍に増えたという話が紹介されていました。「人間がエージェントを 1 セッションずつ監督する」やり方からの脱却を目指すツール、という位置づけです。

SymphonyとGithubの接続イメージ

なぜやろうと思ったか

普段の開発で積み上がる 細かいタスク(依存パッケージ更新、小さなリファクタ、文言修正など)は、一つひとつは数分で終わるものの、積み重なると本来やりたい機能開発の時間を圧迫します。Codex や Claude Code に振ることもできますが、10 件並列で進めようとすると人間側がセッション切り替えとプロンプト作成で疲弊します。OpenAI が Symphony を公開したのは、まさにこの課題への回答という位置づけです。

ただし社内のチケット管理は GitHub Issues + Projects で運用しているため、Linear 専用の Symphony はそのままでは動きません。新しい issue 管理ツールを導入せずに自動化したい、というのが今回 GitHub アダプタを書こうと思った動機です。

Symphony の GitHub 対応はどこまで来ているか

Symphony のリポジトリを覗くと、SymphonyElixir.Tracker という Behaviour が用意されていて、トラッカー連携を差し替えられる設計になっていることが分かります。設定スキーマ側にも owner / repo / project_number といった GitHub 専用フィールドが既に切られて いて、PR 本文のテンプレチェックや workspace 掃除といった補助タスクも GitHub を想定して書かれています。

つまり「周辺の足場はある」のですが、肝心の GitHub トラッカーアダプタそのものは upstream に存在しません(2026 年 5 月時点)。tracker.kind のディスパッチも linearmemory の 2 種類だけで、github を指定しても受け先がない状態でした。

そこで、Behaviour を頼りに GitHub アダプタを自前で書くことにしました。実際に手を動かしてみると、GitHub アダプタを実装するだけではなく、Symphony 本体側で Linear 連携を前提にしている暗黙の挙動を調整する作業も同じくらい必要で、想定より修正範囲は広めでした。具体的にどのように問題と向き合ったのかを順に紹介していきます。

Tracker Behaviour の活用

Symphony のトラッカー連携が Behaviour で抽象化されていたことはありがたかったです。

# lib/symphony_elixir/tracker.ex
defmodule SymphonyElixir.Tracker do
  @callback fetch_candidate_issues() :: {:ok, [term()]} | {:error, term()}
  @callback fetch_issues_by_states([String.t()]) :: {:ok, [term()]} | {:error, term()}
  @callback fetch_issue_states_by_ids([String.t()]) :: {:ok, [term()]} | {:error, term()}
  @callback create_comment(String.t(), String.t()) :: :ok | {:error, term()}
  @callback update_issue_state(String.t(), String.t()) :: :ok | {:error, term()}
end

このコールバックを満たす SymphonyElixir.GitHub.Adapter を実装して、Tracker.adapter/0 のディスパッチに github を追加すれば、本体側のコードに手を入れずに GitHub に対応できます。

# lib/symphony_elixir/tracker.ex
def adapter do
  case Config.settings!().tracker.kind do
    "memory" -> SymphonyElixir.Tracker.Memory
    "github" -> SymphonyElixir.GitHub.Adapter
    _ -> SymphonyElixir.Linear.Adapter
  end
end

WORKFLOW.md の設定

Symphony は WORKFLOW.md というファイルの YAML フロントマターから設定を読み込みます。tracker 周りの設定は、おおよそ次のような形になっています。

# WORKFLOW.md (frontmatter)
tracker:
  kind: github
  api_key: $GITHUB_TOKEN
  owner: <your-org>
  repo: <your-repo>
  project_number: N
  assignee: me
  active_states:
    - Ready
    - In Progress
  terminal_states:
    - Done
    - Pending

主要な設定値の意図を補足しておきます。

  • kind: github: 自前で書いた GitHub Adapter を有効化
  • api_key: $GITHUB_TOKEN: gh auth token で取得したトークンを環境変数経由で渡す
  • assignee: me: 自分のチケットだけに限定し、他人のチケットを誤って触ることを防ぐ
  • active_states: Ready を入口のトリガーにして、準備中・議論中の issue が勝手に走るのを弾く。In Progress を併せて含めるのは後述のステータス遷移と整合性を取るため
  • hooks.after_create / before_run: git clone と、issue ラベルから決まる fix/issue_XXXXXfeat/issue_XXXXX 形式のブランチ作成までを Symphony 側で済ませる

エージェントに任せる準備が整ったタイミングで自分の手でステータスを Ready に動かす、というカンバン的な操作がそのまま dispatch(Symphony が Ready の issue を検出して Codex セッションに割り当てる処理)のスイッチになります。

ハマりどころと対処

1. GitHub API レートリミットの枯渇

症状: 最初に動かしたとき、ポーリングを始めて数時間で 403 rate limit exceeded が出るようになりました。ログを見ると、1 ポーリングサイクルで 10 回近い API コールが発生しています。

原因: 初期実装は Projects の全 issue をページネーションで取得し、クライアント側で assignee やステータスをフィルタしていました。

対処: GitHub Search API に切り替えて、サーバ側で絞り込むようにしました。1 サイクル 1 コールで済みます。

search(query: "repo:<your-org>/<your-repo> is:issue is:open assignee:@me", type: ISSUE, first: 50) {
  nodes { ... on Issue { id, number, title, ... } }
}

2. 日本語プロンプトの UTF-8 問題

症状: Codex への JSON 送信時にエラーでセッションが起動失敗していました。

** (Jason.EncodeError) invalid byte 0xE5 in <<...>>

原因: Symphony のプロンプトは Solid テンプレートエンジンでレンダリングしていて、出力は iodata 形式です。これを IO.iodata_to_binary/1 で文字列化すると、バイト列を素朴に連結するだけなので一部の日本語が破壊されることがあります。

対処: :unicode.characters_to_binary/1 は UTF-8 として正しく解釈してくれるので、こちらに置き換えました。念のため、Codex に送る JSON ペイロードを組み立てる直前にも ensure_utf8_strings/1 で再正規化を入れています。

defp to_utf8_binary(iodata) do
  case :unicode.characters_to_binary(iodata) do
    utf8 when is_binary(utf8) -> utf8
    _ -> IO.iodata_to_binary(iodata)
  end
end

3. GenServer のブロック

Orchestrator は GenServer で実装されているのですが、GitHub API への HTTP コール中に GenServer がブロックすると、ダッシュボードの状態取得(GenServer.call(:snapshot, 15_000))がタイムアウトしてエラー表示になる、という事象がありました。原因は Req.post のオプション指定で HTTP メッセージが呼び出し元 GenServer の mailbox に流れ込んでいたことで、該当オプションを外して解消しています。あわせて、起動時に走るワークスペース cleanup が Orchestrator をブロックしていたのでこちらも廃止し、snapshot 自体のタイムアウトも念のため 120 秒に延ばしました。

4. Project ステータスの遷移を Codex から行う

Symphony の Agent Runner は「Codex のターンが完了しても、Issue が active 状態のままなら continuation メッセージを送ってターンを継続する」という設計になっています。長時間タスクで人間がステータスを動かすことを想定した仕組みです。

症状: プロンプトに何も書かないまま動かすと、dispatch しても Issue のステータスは Ready のまま変わらず、Symphony から見ると永遠に「まだ active」のままになります。Codex 側は会話履歴を毎ターン送り直すので、ターンを重ねるごとに 1 ターンの入力トークンが膨らみ、レートリミットを使い切るまで止まりません。

原因: Symphony 本体は dispatch 自体でステータスを動かしません。SPEC.md には次のように書かれていて、状態遷移は coding agent(Codex 自身)が API ツールで行う、というのが想定された分担です。

Ticket writes (state transitions, comments, PR links) are typically performed by the coding agent using tools available in the workflow/runtime environment.

対処: WORKFLOW.md のプロンプトに、Codex のターン内で完結する 2 段階のステータス遷移 を指示として組み込みました。

  • ターン開始の最初のアクションで 🔖 Ready → 🏗 In progress に遷移
  • gh pr create 成功直後に 🏗 In progress → 👀 In review に遷移

それぞれ gh api graphql で Project の Status フィールドを更新する手順をテンプレート化し、プロンプトに埋め込む形です。Project / Field / Option の各 ID は不変なので、一度取得した値を固定値として書き下しておけば WORKFLOW.md だけで完結します。後段の In review 遷移には、レビュアー側で「In review のものから順に見る」フローが組めるという副次的なメリットもありました。

保険として agent.max_turns も 3 に絞っています。デフォルト値の 20 はやや過剰で、万が一プロンプトの状態遷移処理が失敗した場合でも、トークン消費を一定値で打ち切れるよう低めに設定しておくのが安全です。

5. Codex の設定

Codex CLI はデフォルトで「コード変更のみ」を想定した安全側の挙動になっていて、git 操作・ネットワークアクセス・ワークスペース直下の設定ファイル読み込みなど、明示的に許可しないと通らないものがいくつかあります。GitHub アダプタを運用に乗せるには、以下 2 つの設定が必要でした。

permissions プロファイルの定義

症状: 最初に動かしたとき、Codex が git branch -m でリネームしようとして失敗し、続いて git commitcannot lock ref で失敗しました。

fatal: cannot lock ref 'refs/heads/fix/issue_XXXXX':
Unable to create '.git/refs/heads/fix/issue_XXXXX.lock': Operation not permitted

原因: デフォルトの workspace-write sandbox はワークスペース内であっても .git, .codex, .agents への書き込みを macOS Seatbelt レベルでブロック します。同じ理由で gh pr create の API 通信や Vite dev server のローカルポート bind も遮断されます。SPEC.md には「PR 作成は coding agent が担う」と書かれている一方、デフォルト sandbox では git もネットワークも触れない、という食い違いが顕在化していました。

対処: 必要な権限を permissions プロファイル で明示的に開放します。ワークスペースの .codex/config.toml に profile を定義し、.git への書き込みとネットワークアクセスを許可しています。

# .codex/config.toml
[permissions.symphony-codex.filesystem.":workspace_roots"]
"." = "write"
".git" = "write"

[permissions.symphony-codex.network]
enabled = true
allow_local_binding = true

加えて、Symphony 側からの JSON-RPC で sandbox: "workspace-write" ではなく permissions: "symphony-codex" を送るよう Elixir 側に分岐を入れました(sandboxpermissions は同時指定不可なので排他の分岐です)。

# lib/symphony_elixir/codex/app_server.ex の抜粋
sandbox_params =
  case Map.get(session_policies, :permissions_profile) do
    profile when is_binary(profile) and profile != "" ->
      %{"permissions" => profile}

    _ ->
      %{"sandbox" => session_policies.thread_sandbox}
  end

trusted projects への workspace 登録

症状: 上記の permissions プロファイルを定義したのに、Codex CLI が読み込んでくれないことがありました。

原因: Codex CLI はワークスペース直下の .codex/config.toml を、ユーザーホームの ~/.codex/config.toml[projects."<絶対パス>"]trust_level = "trusted" として登録した workspace でしか読み込みません。trust は親ディレクトリから継承されないため、Symphony のように issue ごとにワークスペースを切る運用ではパスが毎回変わり、登録が間に合いません。

対処: before_run フックの冒頭で ~/.codex/config.toml に該当エントリを idempotent に追記する処理を入れ、人手で登録する手間を省いています。

運用してみての感想

GitHub Adapter を組み込んだ Symphony を実際の自社リポジトリで動かしてみて感じたことです。

良いと感じたところ:

  • Issue を Ready にするだけでエージェントが動き出すフローは想像以上に快適で、「ちょっとした修正をエージェントに任せる」までの心理的・操作的コストが大きく下がります。
  • GitHub Projects のカンバンでステータスを動かす運用がそのままエージェントへのタスク委譲と一体になるので、新しい運用ルールを覚える必要がありません。

気をつけるべきところ:

  • max_turns のデフォルト 20 のまま動かすと、設定ミスがあった場合に短時間で大量のトークンを消費してしまいます。最初は 3 程度から始めるのが安全です。
  • 巨大モノレポではファイル探索が遅く、Codex がコードベースを走査する段階で相応のトークンを消費します。Serena のような MCP のインデックス検索を併用すると改善が見られました。
  • continuation ターンが回るたびに会話履歴ごと再送される構造なので、ターンが長くなるほど 1 ターンあたりの入力トークンが膨らみます。コードに直接関係しないルール文書(CLAUDE.mdAGENTS.md)はできるだけスリムに保つことが、コスト面でも効いてきます。

おわりに

Symphony は本来 Linear 専用ですが、Tracker Behaviour の抽象化が綺麗に効いているおかげで、GitHub Adapter の追加自体は素直に書けました。ハマりどころの多くは GitHub 固有の API 制約・Codex sandbox の挙動・hook の設計といった周辺仕様に集中していて、Symphony 本体の設計に起因するものは多くありません。とはいえ2026 年 5 月時点で GitHub Issues に乗せるには、本記事で扱った通り相応の作り込みが必要で、初期コストは決して小さくありません。今後 Symphony 側で GitHub サポートが本格化すれば、もっと手軽に試せるようになると思います。

一方で「Issue を置くだけで PR が出てくる」というワークフロー自体は、実際に動かしてみると体験として非常に良いものでした。同じ構成を再現しようとして詰まる方がいたときに、この記事がデバッグの最短経路として届けば嬉しく思います。

参考リンク