【しくじり話】gh issue list は1点、gh project item-list は203点 — AIエージェント11席で GraphQL 枠を20分で焼いた話

投稿 最終更新

文:株式会社佐野組 佐野 凛

事故

AIエージェントを11席、だいたい10分おきに自律巡回させている開発組織で、Priority を P1 にした Issue が、およそ1時間だれにも振られなかった。

時刻の流れはこうだった。

04:46  P1 を設定
04:59  差配席が巡回 … 候補に出ない
05:20  差配席が巡回 … 候補に出ない
05:31  差配席が巡回 … 候補に出ない

判定ロジックを読み取り専用で手から叩くと、正しく候補に出る。ロジックは壊れていなかった。席も「見ていないもの: OPEN Issue 一覧と Kanban の item-list(gh が非0)」と正直に申告していた。

最初は GitHub CLI 側の不具合だと思った。違った。原因は、自分たちが書いた巡回コードだった。

見た目が同じ2本で、値段が200倍違う

同じ gh、同じ --limit、同じ --json でも、GraphQL の消費点がまるで違う。

$ gh issue list --limit 200 --json number,title,labels,body,updatedAt
  →   1 点

$ gh project item-list 1 --owner <org> --format json --limit 400
  → 203 点

測り方は単純で、GH_DEBUG=api を付けたときのレスポンスヘッダ X-Ratelimit-Used の差分を、前後で挟んで3回ずつ取った。ばらつきは無かった。

効くのは --limit の数字そのものではなく、盤の実データ件数だ。

--limit実測
1011 点
5054 点
100105 点
400203 点(実データ 127 件)

なぜそうなるか — 入れ子の connection

GH_DEBUG=api で、gh が実際に投げているクエリを見ると、だいたい次の形になっている(執筆時点の gh)。

items(first: $firstItems, after: $afterItems) {
  ...
  fieldValues(first: 100) { ... }
}

GitHub 公式ドキュメント「Rate limits and node limits for the GraphQL API」は、ポイントの見積もりをこう書いている。

Add up the number of requests needed to fulfill each unique connection in the call. Assume every request will reach the first or last argument limits.

Divide the number by 100 and round the result to the nearest whole number to get the final aggregate point value.

ユーザー向けの枠は、だいたい「1時間あたり 5,000 点」だ。

ここから単純に計算すると、items(first:100) × fieldValues(first:100) は 10,000 ÷ 100 で、1ページあたり 100 点になる。

そして --limit 400 が、いちばん悪い効き方をする。実データ 127 件は2ページになり、2ページ目は 27 件しか返らないのに、満額の 100 点を取られる。「Assume every request will reach the first limits」が、そのまま効く。100 + 100 に端数が乗って 203。実測と一致した。

gh issue list 側には、この入れ子の connection が無いので 1 点で済む。同じ CLI なのに、値段だけが約200倍違い、その差額はどこにも表示されない。

fieldValues(first: 100) は、いまの gh が選んでいる実装値だ。将来の版では変わり得るので、記事の式は「執筆時点の gh が投げるクエリ」として読んでほしい。

×11席 — 予算を連続で超える

巡回コードは、この呼び出しに席の区別を付けていなかった。巡回控えから集計した実数は、こうだった。

時刻帯巡回回数席数item-list だけの消費予算 5,000/時 に対して
0時間前36117,308 点146%
1時間前44118,932 点179%
2時間前29115,887 点118%

3時間連続で予算超過。だいたい20〜30分で枠が焼け、残りの時間は全席が盲になる。推定ではなく、巡回ログの実行時刻を時間で刻んだ実数に、実測単価を掛けた結果だ。

落ち方が悪い — 黙って消える

スナップショット取得は、2本を続けて取る設計だった。

取るものレート制限時
gh issue listREST への退避を書いてあった
gh project item-list退避が無い。Projects v2 は GraphQL 専用

後者が落ちると return null になる。呼び出し側は if (snap === null) return; なので、ふたつの検査が同時に、例外も警告も出さずに消える。

しかも gh は、GraphQL のレート制限を unknown owner type という、まったく無関係に見える文言に潰して返すことがある。ログを読んでも、枠切れだと分からない。

Projects v2 に REST の代替は、classic Projects の REST 口を叩いても 404 で、代替にはならなかった(こちらも実測)。

安全弁が、死んだ計器に繋がっていた

枠切れを予防するために、「残枠が細ければ重い呼び出しを飛ばす」ガードを書いてあった。その入力が gh api rate_limitgraphql 欄だった。ところが、この欄は常に used=0 / remaining=5000 で、一度も動かなかった。

gh api rate_limit の申告          実ヘッダ(同じセッション・同じトークン)
graphql used=0  remaining=5000    X-Ratelimit-Used: 1356 → 1458 → 2596 → 2805
core    used=98 remaining=4902    ← core は正しく動く

同じ応答の中で core は正しく、graphql だけが死んでいる。つまり安全弁は、永久に「健全」としか言わない。

執筆時点でも、こちらで gh api rate_limit を叩くと graphql.used が 0 のまま返ることがあった。一方、実際の GraphQL 呼び出しのレスポンスヘッダでは X-Ratelimit-Used が増えていた。条件を classic PAT 以外(fine-grained PAT や GitHub App トークン)まで絞ってはいないので、GitHub 側の既知不具合かどうかは断定しない。確実に言えるのは、「ヘッダを見ずに REST の申告だけを信じるな」ということだ。

公式も、できるときはレスポンスヘッダを使え、と書いている。

書くな、の5箇条

ニッチな gh の小ネタに見えて、中身は AIエージェントが最も書きやすいコードの型だ。「巡回で Kanban の Priority を見て」と頼めば、まずこう書く。

  1. 手近な CLI をそのまま呼ぶ。 gh issue list が安いので、gh project item-list も安いと思う。
  2. --limit は大きめにしておく。 「取りこぼすよりマシ」——その1行が、ページ課金を倍にする。
  3. 全席に同じ処理を配る。 1席で問題が無かったので、11席でも問題が無いと思う。
  4. 安全弁を書く。 ただし、その計器が正しいかは確かめない。
  5. 失敗を握りつぶす。 if (snap === null) return; は行儀よく見えて、いちばん害が大きい。

5つとも、単体レビューでは通る。掛け算になって初めて、「P1 が1時間放置される」に化ける。

gh は仕様どおりに動いている。しくじったのは、呼ぶ側だ。

出典

© 2026 株式会社佐野組