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?

自律開発ループが「自分のリトライ記録」を push して、10時間で94周した

0
Posted at

自律開発ループが「自分のリトライ記録」を push して、10時間で94周した

GitHub Actions 上で Claude のエージェントを回している自律開発ループが、1本の Issue で10時間・94周し、セッション枠を3つ焼きました。

原因は一行で言えます。

失敗の記録(リトライ回数)を、再実行の起爆点(PR の HEAD)へ commit していた。

しかも、止めるための安全装置は3つとも実装済みでした。3つとも効きませんでした。本記事はその調査記録と、効かなかった理由、そして修正差分です。


前提:どういうループか

同じループの「止まる側」の失敗は別記事(自律開発ループが止まる理由を分解したら、5つ目がAIエージェント自身だった)に書いた。本記事はその逆——止まらない側の失敗である。

  • GitHub Issue が起点。ラベルでフェーズが進む(auto:planning → auto:implementing → auto:review)
  • 各フェーズは Node のオーケストレータ(plan.mjs / implement.mjs / review.mjs)が Claude Agent SDK でエージェントを起動する
  • ワークフローは3本(auto-01-plan / auto-02-implement / auto-03-review-merge)+ 定時スイーパー(auto-05-sweep, cron */30)
  • レビューフェーズは 3体並列(仕様・個人情報・品質)

エラーは種別で扱いを分けていました。

種別 例 扱い
基盤 認証失敗・クレジット切れ・session limit auto:retry を付けて停止。スイーパーが冷却60分後に再開
一過性 429 / 5xx 指数バックオフ
タスク テスト失敗・型エラー オーケストレータ内で反復
仕様 前提が崩れた auto:blocked(人間の判断待ち)

さらに「同じエラー指紋が2回続いたら止める」という規定もありました。状態は automation/state/issue-<n>.json に置き、エージェントの記憶に依存させない設計です。

一見、手当てはされています。


何が起きたか

オーナーからの報告はこうでした。

You've hit your session limit · resets 3:20am (UTC) で何度もループしていて、Issue のコメントが多すぎた

実測がこれです。

指標 実測値
暴走時間 10時間12分(3.4分間隔・94周)
Issue の総コメント 427件(うちループ由来 371件=87%)
session limit のエラーコメント 277件
空コミット fix: レビュー指摘への対応 97件
焼いたセッション枠 3枠連続(resets 5:20pm → 10:20pm → 3:20am)
同期間の workflow run レビュー系 125回 / Claude コードレビュー 597回 / CI 220回
state の最終値 attempts.infra: 249 / repeatedFingerprint.infra: 122

repeatedFingerprint: 122 が全てを語っています。「同じ失敗を122回繰り返した」と自分で記録しながら、123回目を実行していた。

そして session limit はアカウント単位です。つまりこの10時間、他のタスクのエージェントも一切動けませんでした。1本の Issue が、開発全体を止めたわけです。


調査:何を見て特定したか

再現手順ではなく、残された痕跡から辿りました。GitHub CLI だけで足ります。

1. コメントの内訳を数える

gh issue view 147 --json comments > c.json
import json, re, collections
d = json.load(open('c.json'))['comments']
print('total:', len(d))
sl = [c for c in d if "hit your session limit" in c['body']]
print('session limit:', len(sl))
print('window:', sl[0]['createdAt'], '->', sl[-1]['createdAt'])
print(collections.Counter(re.findall(r'resets ([\d:apm]+)', ' '.join(c['body'] for c in sl))))
total: 427
session limit: 277
window: 2026-09-22T14:36:42Z -> 2026-09-23T00:47:57Z
Counter({'5:20pm': 126, '10:20pm': 123, '3:20am': 28})

3つのリセット時刻が出た時点で「枠が回復するたびに使い切っていた」と確定します。

2. 同種の痕跡が他にもあるか

暴走が1件か、構造的に全部そうなのかで対処が変わります。

gh api "repos/OWNER/REPO/issues?state=all&per_page=100" --jq '.[] | "\(.comments)\t#\(.number)"' | sort -rn | head
for n in 149 99 29 23 18; do
  echo -n "#$n: "
  gh api "repos/OWNER/REPO/issues/$n/comments?per_page=100" --paginate --jq '.[].body' \
    | grep -c 'hit your session limit'
done

結果は #147 が 277件、他は 0〜23件。しかも他の Issue は約3時間間隔でした。つまり冷却60分の設計どおりに動いていた。暴走したのは1本だけで、そこだけ違う経路が使われていた、と分かります。

3. 何が run を再起動したのか

ここが決め手でした。

gh api "repos/OWNER/REPO/actions/runs?created=2026-09-22..2026-09-23&per_page=100" --paginate \
  --jq '.workflow_runs[] | select(.name|test("ループ")) | "\(.created_at) \(.name) event=\(.event)"' | sort | tail
2026-09-23T00:37:09Z 自律ループ 03 検証・マージ event=pull_request success
2026-09-23T00:40:38Z 自律ループ 03 検証・マージ event=pull_request success
2026-09-23T00:44:02Z 自律ループ 03 検証・マージ event=pull_request success

event=pull_request。ラベルではなく PR の更新で再起動していました。スイーパー(event=schedule)はこの間わずか数回しか走っていません。

PR が更新された=誰かが push している。ならば何を push したのか。

gh pr view 149 --json commits --jq '[.commits[] | select(.messageHeadline|test("fix:"))] | length'
# => 97

sha=$(gh pr view 149 --json commits --jq '.commits[-1].oid')
gh api "repos/OWNER/REPO/commits/$sha" --jq '.files[] | {f:.filename, add:.additions}'
# => {"f":"automation/state/issue-147.json","add":3}

97個の空コミット。中身は state ファイルの3行だけ。

   "attempts": {
-    "infra": 246,
+    "infra": 249,
   "repeatedFingerprint": {
-    "infra": 119,
+    "infra": 122,

リトライ回数の増分そのものを commit していました。


原因

review エージェント3体が session limit で失敗
   ↓
「修正を push する」ステップが if: always() で走る    ← 失敗しても実行される
   ↓
dirty なのは automation/state/issue-147.json(試行回数)だけ → commit & push
   ↓
pull_request(synchronize) が auto-03 を再起動          ← ラベル経路ではない
   ↓
また3体が session limit で失敗(3.4分周期)

該当ステップはこうなっていました。

      # 修正が入った場合のみ push する。push が ci.yml を起こし、
      # pull_request(synchronize) でこのワークフローが再び回る(設計 §6.2 の修正ループ)
      - name: 修正を push する
        if: always()
        run: |
          if git diff --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]; then
            echo "修正なし。push しません"
            exit 0
          fi
          git add -A
          git commit -F - <<'MSG'
          fix: レビュー指摘への対応
          MSG
          git push origin HEAD:"$BRANCH"

コメントには「修正が入った場合のみ push する」と書いてあります。意図は正しい。ところが「修正が入った」の判定が git diff --quiet、つまり作業ツリーが汚れているかでした。

そしてオーケストレータは、失敗したときにも必ず state を書きます。設計上そうしてあるのです(「再開は PR + Issue + state から復元できること」)。

意図は「エージェントの成果物を push する」。実装は「汚れていたら push する」。この2つがズレる唯一のケースが、失敗時でした。


なぜ安全装置が3つとも効かなかったか

安全装置 実装状況 なぜ効かなかったか
auto:retry + 冷却60分 ✅ 実装済み・他 Issue では機能 ラベル経路の再開しか見ていない。再入は PR 経路で起きた
同一指紋2回で停止 ✅ 実装済み・122回まで計数 run の中では効く。run をまたいだ再入は、起動時に state を読んで判定していなかった
concurrency グループ ✅ 実装済み 3.4分間隔なので多重起動ではない。1本ずつ正しく直列に回りながら燃えた

3つとも「正しく動いていた」のが厄介なところです。監視対象がズレていただけ。

  • 見ていたもの:Issue のラベル(フェーズの状態)
  • 実際の制御面:PR の HEAD(コミット)

この2つを別々の人格が管理していて、片方にしか門番がいませんでした。

追い打ち:同じ欠陥は隣のワークフローで既に直っていた

調査中にこれを見つけました。

# .github/workflows/auto-02-implement.yml
# ⚠️ 2026-09-14 修正: automation/state/ を除外する。
#   implement.mjs 自身が automation/state/issue-<n>.json を必ず書くようになり、
#   **正当な実装が必ずここで落ちる**ようになった。Issue #29 が実際にこれで停止した。
OUTSIDE="$(printf '%s\n' "$CHANGED" | sed 's/^...//' \
  | grep -v '^tests/' | grep -v '^automation/state/' || true)"

8日前に、隣のワークフローで同じ問題を踏んで直していました。 症状が違ったので(あちらは「落ちる」、こちらは「回り続ける」)、同じ原因だと気づかれず横展開されなかった。

「state ファイルは他のファイルと性質が違う」という知見が、1箇所のコメントにしか書かれていなかったのが本当の敗因です。

さらに増幅していたもの

synchronize は PR レビュー用の別ワークフローも起こします。同じ2日間で 597 run。失敗ループは自分の3体だけでなく、周辺のワークフローも道連れにして枠を削っていました。


一般化:自律ループを組む人へのチェックリスト

今回の失敗は「Claude が悪い」でも「Actions が悪い」でもなく、フィードバックループの設計ミスです。同じ形は言語やSDKを問わず踏めます。

  1. 失敗の記録を、再実行のトリガー面に書いていないか
    リトライ回数・最終エラー・試行時刻。これらを PR のブランチ、監視対象のディレクトリ、webhook を飛ばすテーブルに書いていたら、それは燃料です。記録は制御面の外に置く(別ブランチ、Actions の cache/artifact、外部ストア)。
  2. 再入の経路を数え上げたか
    「このジョブは何によって起動するか」を全部列挙します。issues: labeled / pull_request: synchronize / schedule / workflow_dispatch / 手動再実行。クールダウンやリトライ上限は、経路ごとではなく全経路に効く場所に置く。
  3. if: always() の中に、状態を変える操作が入っていないか
    後始末(ログ収集・通知)は always() で良い。push・コメント・ラベル変更・DB 書き込みは別です。「失敗時にも実行される」=「失敗時にも副作用が出る」。
  4. カウンタを、増やすだけで終わっていないか
    attempts を記録しているコードは、起動直後にそれを読んでいますか。書く側と読む側が別のタイミングにあると、今回のように122回記録して123回目を走らせます。
  5. 並列 N 体の失敗通知は N 件出る
    3体並列で1周3件、94周で277件。通知は「最初の1件だけ」に絞る。ノイズが87%を占めると、人間はその Issue を読むのをやめます。実際オーナーが気づいたのは「コメントが多すぎる」という量の異常でした。
  6. 「自動再開」と「人間を呼ぶ」の境界を、回数で引く
    一過性の障害は自動再開でよい。ただし同じ失敗が N 回続いたら、それは一過性ではない。自動再開の対象から外して人間に渡すべきです。今回の実害は「復旧を待てなかったこと」ではなく、待ち方が無限だったことでした。
  7. 1本のタスクが全体を止められる資源を握っていないか
    API のセッション枠・レート制限・同時実行枠。共有資源を1タスクが独占しうる構成なら、タスク単位の上限が必要です。

直した差分

結論はコードで書くのが一番早いので、そのまま置きます。

1. 燃料を切る(これ単体でループは止まる)

-          if git diff --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]; then
-            echo "修正なし。push しません"
+          # ⚠️ 修正(Issue #147 の暴走): automation/state/ だけの差分では push しない。
+          #   このステップは `if: always()` であり、レビューが基盤エラー(session limit 等)で
+          #   落ちた場合も実行される。そのとき dirty なのは review.mjs が必ず書く
+          #   automation/state/issue-<n>.json(試行回数)**だけ**である。
+          #   それを push すると pull_request(synchronize) で本ワークフローが再起動し、
+          #   また同じ基盤エラーで落ちて、また state を push する——という自己再起動になる。
+          REAL="$(git status --porcelain | sed 's/^...//' | grep -v '^automation/state/' || true)"
+          if [ -z "$REAL" ]; then
+            echo "修正なし(automation/state/ の記録だけ)。push しません"
             exit 0
           fi

2. 入口ゲート:起動前に自分の履歴を読む

export const INFRA_REPEAT_LIMIT = 2;

/** 同じ基盤エラーで再入を繰り返しているか(= もう起動しても無駄か)。 */
export function infraLoopExhausted(state) {
  return (state?.repeatedFingerprint?.infra ?? 0) >= INFRA_REPEAT_LIMIT;
}

/** 種別の試行記録を消す。フェーズが最後まで通ったときに呼ぶ。 */
export function resetAttempts(state, kind) {
  state.attempts[kind] = 0;
  state.repeatedFingerprint[kind] = 0;
  state.lastFingerprint[kind] = null;
  return state;
}
// 入口ゲート: 同じ基盤エラーでの再入を止める。
// recordAttempt は run の中ではリトライしないが、run をまたいだ再入は誰も見ていなかった。
// ここで止めればエージェントを1体も起動しないため、セッション枠を消費しない。
if (infraLoopExhausted(state)) {
  await stop('基盤エラーの反復', [
    `同一の基盤エラーが ${state.repeatedFingerprint.infra + 1} 回続いています`,
    'エージェントを起動せずに停止しました。自動再開の対象から外します。',
  ].join('\n'));
}

リセットを入れるのを忘れないこと。 消さないと、過去の障害の記録だけで次回以降が入口で止まります(今回の state は既に122まで育っていたので、これが無いと永久に起動しません)。

3. 反復は「自動再開」から「人間の判断」へ移す

 export function classifyBlock(kind, text) {
   const s = `${kind ?? ''}\n${text ?? ''}`;
+  // 反復した基盤エラーは「自動再開しても同じ所で落ちる」ため、
+  // credit/infra(スイーパーが再開する)ではなくオーナー判断へ落とす。
+  if (String(kind ?? '').includes('基盤エラーの反復')) return 'infra-exhausted';
   if (CREDIT_PATTERNS.test(s)) return 'credit';
   if (String(kind ?? '').includes('基盤エラー')) return 'infra';
   return 'spec';
 }
-          if [ "$KIND" = "spec" ]; then
-            NEW_LABEL="auto:blocked"
-          else
-            NEW_LABEL="auto:retry"
-          fi
+          case "$KIND" in
+            spec|infra-exhausted) NEW_LABEL="auto:blocked" ;;
+            *)                    NEW_LABEL="auto:retry" ;;
+          esac

順序が重要です。CREDIT_PATTERNS は /session limit|usage limit|quota|.../i なので、反復判定を先に置かないと credit(自動再開)に落ちて元の挙動に戻ります。

4. 通知を1件に絞る

// レビュー3体は並列で走るため、基盤エラーでは3体が同時に落ちる。
// 旧実装は1体ごとに Issue コメントを出しており、1周で3件・94周で計277件を積んだ。
let infraReported = false;

async function fatal(message, detail) {
  recordAttempt(state, 'infra', detail ?? message);
  if (infraReported) { await saveState(state); process.exit(0); }
  infraReported = true;
  await stop('基盤エラー', message, detail);
}

5. 回帰テストで固定する

一番効くのがこれです。ワークフローの1行は、誰かが「整理」した瞬間に消えます。

describe('auto-03 が state だけの差分を push しない(再起動の燃料を切る)', () => {
  const yaml = readFileSync('.github/workflows/auto-03-review-merge.yml', 'utf8');

  it('push の前に automation/state/ を除外している', () => {
    expect(yaml).toMatch(/grep -v '\^automation\/state\/'/);
  });
});

it('★ session limit という語に引きずられて credit(自動再開)へ落ちない', () => {
  expect(classifyBlock('基盤エラー', SESSION_LIMIT)).toBe('credit');
  expect(classifyBlock('基盤エラーの反復', SESSION_LIMIT)).not.toBe('credit');
});

YAML の設定値をテストで固定するのは変に見えますが、「この1行が消えるとアカウントの枠が溶ける」種類の1行には見合います。


おわりに

自律開発ループの失敗談は「AI が変なコードを書いた」という話になりがちですが、実際に痛かったのはそこではありませんでした。エージェントは正しく失敗を報告し、回数も正しく数え、ワークフローは正しく直列に回していた。 それぞれが正しく動いて、全体として10時間燃えました。

自律ループを組むということは、フィードバック制御系を書くことです。センサー(state)とアクチュエータ(push)を同じ面に置いてはいけない、という話に尽きます。

そして最後にひとつ。この暴走は「オーナーがコメントの多さに気づいた」ことで発覚しました。 監視が人間の違和感に依存していたわけです。次に直すべきはそこだと思っています。

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?