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