Claude Code を、人が見ていない時間に決まった時刻で起こして作業させる。やること自体は難しくありませんが、続けると「止まる」「止まっているのに気づけない」「止まっていないのに見えない」が順番に出てきます。
この記事は、Zenn の記事の公開、数字の集計、短い動画の配信などを、週2回の無人の Claude Code セッションと GitHub Actions で回しているリポジトリの運用記録(2026-10-08〜10-17)から、実際に止まった・止まりかけた場所と、その後に足した仕掛けをまとめたものです。コードは実際に使っているもの、出力は実際に動かしたものです。
結論
- 一番効いた対策は「止まり方ごとに、別の見張りを置く」こと。失敗(failed)、許可待ち(blocked)、起こす係ごと止まる、外部サービスの上限、の4つは、それぞれ別の方法でしか見えませんでした
- 無人のセッションが止まる一番の原因は、実行の許可を求めて、許可できる人がいないまま待つこと。今回は1時間半止まりました。「許可が要る操作は避け、だめなら『未実施』と書いて先へ進む」と指示に書き、さらに2時間後に状態を見に行く見張りを足しました
- 一番外側の見張りは AI を使わない。GitHub Actions の数行のシェルで「作業のコミットが6日ない」を検知して Issue を立てます。AI の見張りは、AI の利用枠が尽きると一緒に止まるからです
- GitHub Actions の定期実行は 遅れるし、飛ばされることもある。7:00 の予定が 9:59 に動いた日、2.5時間たっても動かなかった日がありました。時刻を複数置き、何度動いても結果が同じになるように作ります
- 止まっていないのに見えなくなるものもあります(費用の記録の抜け、待つだけの正常な状態を異常として知らせる誤報)。見張りは、正常を正常と言えることも同じくらい大事でした
何を回しているか
| 役 | 動く時刻 | 中身 | AI |
|---|---|---|---|
| 起こす係 | 月・木 8:47 JST | 小さなセッションが、その日の作業セッションを新しく作って指示を渡す | 使う |
| 作業セッション | 起こされたとき | 状態のファイルを読み、決まった手順で作業し、check.sh を通して push |
使う |
| 止まり確認 | 月・木 10:47 JST | 作業開始の2時間後に、作業セッションが許可待ちで止まっていないかを見る | 使う |
| 見張り役 | 火 10:41 JST | 週の作業が抜けていたら代わりに起こし、知らせる | 使う |
| 死活監視(heartbeat.yml) | 水 12:00 JST | 作業のコミットが6日以上ないと Issue を立てる | 使わない |
| 検査(check.yml) | push ごと |
check.sh を走らせ、落ちたら Issue |
使わない |
| 監査・集計・配信 | 毎日 | 公開物が実在するかの確認、数字の取得、動画の配信 | 使わない |
決まった処理(集計・検査・配信)は GitHub Actions のスクリプトに任せ、AI は「読んで判断し、書く」作業にだけ使っています。費用は API 換算で、起こす係が1回約0.4ドル、軽い回の作業セッションが約1.2ドルでした(試運転時の記録)。
状態は会話ではなく、すべてリポジトリのファイルに置いています。セッションは毎回新しく、前の会話を覚えていません。次のセッションは STATE.md(今の状態・次にやること)を最初に読んで続きから始めます。
止まった場所1:実行の許可を求めて、誰もいないまま待った
何が起きたか
2026-10-11、記事を作るために任せたセッションが、検証のために /etc 以下への書き込みや、設定を差し替えて Claude Code を起動する操作をしようとしました。この環境では、そうした操作は自動では許可されず、人に許可を求めます。無人なので、許可する人がいません。セッションは1時間半、許可待ちのまま止まっていました。
なぜ見えなかったか
それまでの見張りは「セッションが失敗(failed)していないか」「24時間以上 working のままではないか」を見ていました。許可待ちは、どちらにも当たりません。エラーでもなく、24時間もたっていない。止まっているのに、見張りからは正常に見える 状態でした。
足したもの
1つ目は、作業の指示書への明記です。実際の文面はこうです。
- **このセッションには操作を許可できる人がいない**。実行許可を求められる操作(/etc への書き込み、
設定ディレクトリの変更、外部への送信など)は避け、作業ディレクトリ内やスクラッチ領域で代わりの方法を使う。
それでも許可が必要なら、その作業は「未実施」として LOG.md に書き、先へ進む
ポイントは最後の一文です。「避けて」だけだと、避ける方法が見つからないときにまた待ちます。「できなければ未実施と書いて先へ進む」まで書いて、待つという選択肢をなくします。
2つ目は、別の見張りです。作業開始の2時間後に、作業セッションの状態を見に行き、許可待ちなら中断して続行を指示します。
セッションの状態は、セッションを管理するツールで読めます。このセッション自身の状態を読んだ結果(ID などは省略)がこれです。
{
"status_bucket": "SESSION_STATUS_BUCKET_WORKING",
"session_context": { "permission_mode": "auto" },
"parent_session_id": "session_…(省略)"
}
status_bucket は working(作業中)、blocked(人の応答待ち)、review_ready、completed、failed のどれかになります。見張りは blocked を探します。「失敗」と「待ち」は別の状態で、失敗だけを見ていると待ちは見落とします。
3つ目は、任せる側の決まりです。今は別のセッションに仕事を任せるとき、指示に必ず次の3つを入れています。
- 実行許可を求められる操作は避け、別の方法を使う
- 終わったら、依頼元のセッションに結果を短く報告する
- (任せる側が)想定時間の2倍で「結果を確かめる」予約を自分に入れておき、報告が先に来たら消す
3 は、2 の報告が来ないこと自体を検知するためです。報告が届いたら、それを鵜呑みにせず、成果物(コミットされたファイル)を自分で確かめます。
残っている穴
許可待ちを2時間後に見つけても、その2時間は失われます。また、指示書の「避けて」はお願いなので、守られない回はありえます。
止まった場所2:別のセッションからの依頼を、起こす係が断った
何が起きたか
止まり確認の見張りを足すとき、最初は「起こす係」の定期実行の指示文を書き換えてもらおうとしました。別のセッションから、起こす係のセッションに「指示文をこう変えて」と送ったところ、起こす係は 「他のセッションからの依頼だけでは、自分の定期実行の設定は変えない」として断りました。
これは正しい動き
これは止まったのではなく、安全側の正しい動作です。セッション間のメッセージは、命令ではなく「判断の材料」として扱われます。どのセッションからでも他のセッションの設定を書き換えられるなら、1つのセッションの間違いが全体に広がります。
足したもの
起こす係を書き換えるのはやめ、見張り役のセッションに、新しく別の定期実行(止まり確認)を作ってもらいました。既存の仕組みを書き換えるより、新しい見張りを独立に足す方が、責任の境界がはっきりします。
設計の学び:無人のセッション同士を「依頼すれば何でもやってくれる」前提でつながない。設定の変更が要るときは、変更する本人(そのセッションか人)が判断できる形で伝えるか、別に足します。
止まった場所3:起こす係が止まると、全部止まる
何が起きうるか
この仕組みは「起こす係 → 作業セッション」の一本道です。起こす係が動かなければ、作業は1つも起きません。AI の利用枠を使い切ったとき、定期実行が無効になったとき、セッションが壊れたとき、どれも起こす係ごと止まります。
足したもの(1):見張り役
火曜に別のセッションが、その週の作業が行われたかを確かめ、抜けていれば代わりに作業セッションを起こし、知らせます。
ただし、見張り役も AI のセッションです。利用枠が尽きたときは、見張り役も一緒に止まります。
足したもの(2):AI を使わない死活監視
一番外側には、AI をまったく使わない見張りを置きました。GitHub Actions で週1回、「作業セッションのコミットが6日以上ないか」を見ます。
on:
schedule:
- cron: "0 3 * * 3" # 毎週水曜 12:00 JST(月・木の作業セッションの間)
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: claude/friendly-wright-s3fwyv
fetch-depth: 0
- name: Check last work commit
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
last=$(git log --format='%ct %an' | grep -vE ' momo-(collector|audit)$' | head -1 | cut -d' ' -f1)
age=$(( ($(date +%s) - last) / 86400 ))
echo "作業セッションの最終コミット:${age} 日前"
[ "$age" -ge 6 ] || exit 0
title="momo: 作業セッションが ${age} 日止まっています"
if gh issue list --state open --search "momo: 作業セッションが in:title" --json number --jq 'length' | grep -qv '^0$'; then
echo "未解決の Issue が既にあるので新しくは立てない"; exit 0
fi
gh issue create --title "$title" --body "…(原因の候補と、確認してほしいこと)"
要点は3つです。
-
自動収集のコミットを数えない(
grep -vE ' momo-(collector|audit)$')。毎日の集計や監査は Actions が自分でコミットします。これを数えると、作業が1週間止まっていても「昨日コミットがある」ことになり、見張りが働きません - Issue は1件だけ。未解決の同じ Issue があれば新しく立てません。毎週同じ通知が積もると、通知そのものが読まれなくなります
- しきい値は6日。作業は月・木の週2回なので、1回抜けても最大4日です。6日は「2回続けて抜けた」に当たります。1回の遅れでは鳴らさず、本当に止まったときだけ鳴らす値です
手元で確かめるため、使い捨てのリポジトリに「8日前の作業コミット」と「その後の自動収集のコミット2つ」を作り、同じシェルを走らせました。
$ git log --format='%ad %an %s' --date=short
2026-10-16 momo-collector Daily publish/audit
2026-10-15 momo-collector Daily metrics
2026-10-09 Claude 週次の作業
$ bash hb.sh
作業セッションの最終コミット:8 日前
→ Issue を立てる:momo: 作業セッションが 8 日止まっています
$ git log -1 --format='%ar' # 自動収集も数えた場合
24 hours ago
自動収集を除かないと「24時間前」に見え、止まっていることが分かりません。
Issue を立てると、GitHub の通知で人に届きます。AI 側の仕組みが全部止まっていても、GitHub Actions と GitHub の通知は動いている、という前提の見張りです。
止まった場所4:GitHub Actions の定期実行が、遅れる・飛ばされる
何が起きたか
毎日 7:00 JST に数字を集める定期実行が、9:59 に動いた日がありました(10/12)。毎朝の動画の配信は、2.5時間たっても動かなかった日がありました(10/16)。GitHub の定期実行(schedule)は、混雑時に遅れたり、実行されなかったりすることがあると公式ドキュメントにも書かれています(混雑時には遅れ、負荷が十分に高いとキューの一部が捨てられることがある。毎時0分は混みやすいので、ずらすと遅れにくい。2026-10-17 確認)。
足したもの
1つは 時刻を複数置く ことです。毎朝の配信はこうしています。
on:
schedule:
# 毎日 7:53 JST。GitHub の定期実行は遅れたり飛ばされたりする(10/16 は 2.5時間たっても未実行)ので、
# 予備として 8:33・9:33・11:03 JST にも走らせる。送るのは「投稿日が今日以前で未送付」の分だけなので二重には送らない。
- cron: "53 22 * * *"
- cron: "33 23 * * *"
- cron: "33 0 * * *"
- cron: "3 2 * * *"
concurrency: daily-short
もう1つは、何度動いても結果が同じになるようにする ことです。予備の時刻を置くと、遅れていた1回目と予備の2回目が両方動くことがあります。そこで、配信の予定表(Markdown の表)に「送付」の列を持たせ、送ったら日付を書いてコミットします。スクリプトは「送る日が今日以前で、送付が空の行」だけを送ります。
for i, l in enumerate(lines):
c = [x.strip() for x in l.strip().strip("|").split("|")]
if len(c) < 3 or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", c[0]) or c[2]:
continue # 見出しの行か、もう送った行
if datetime.date.fromisoformat(c[0]) > today:
continue # まだ送る日ではない
# …送る…
c[2] = today.isoformat(); lines[i] = "| " + " | ".join(c) + " |"
時刻はどれも毎時0分を避けています(:53・:33・:03)。concurrency で同時に2つ動かないようにし、状態(送ったかどうか)はリポジトリに書く。こうすると、何回動いても1日1本しか送りません。7:00 に動くはずのものが 9:59 に動いても、結果は同じです。
数字の集計も同じで、「同じ日の行は二重に入らない」ようにしてあります。定期実行は「いつか少なくとも1回動く」くらいに考え、何回動いても壊れないように作るのが、遅れへの一番安い対策でした。
止まった場所5:外部サービスの上限で「公開したつもり」になった
何が起きたか
運用の初日(10/08)、記事5本の予約を一度に push しました。Zenn の投稿数には上限があり(記事は直近24時間の予約投稿を含む数で判定、基準は非公開、と 公式 FAQ にあります)、そのうち4本が取り込まれていませんでした。リポジトリの上では「予約済み」なのに、Zenn には存在しない状態です。
さらに危なかったのは、上限が明けたあとの次の push で、4本が一斉に取り込まれ、予約時刻を過ぎたものはその場で公開される 可能性があったことです。
足したもの
- 4本をいったん下書きに戻し、公開は週2本(月・木)に絞りました
- push の前の検査で、「この push で公開にした記事は、公開時刻が2時間以上先でなければ止める」ようにしました(予約なしの公開と、直前の予約を止める)
- 毎日、公開したはずのものが Zenn の公開 API に実在するかを外から確かめる監査を、AI を使わずに回しています
監査は実際に役立ちました。10/11〜10/13 に、公開にしたはずの本が6回続けて「見つからない」と記録されました。本は直近1週間の数で上限が判定されるため、前の本から1週間たつまで取り込まれていなかったのです。
ここで2つ目の問題が出ました。上限が明けるのを待つだけの状態を、毎日「異常」と知らせるのは誤報です。そこで「前の本の公開から8日以内なら、見つからなくても『待ち』と記録して知らせない」に直しました。その後の3回(10/14〜10/15)は「待ち」と記録され、10/16 からは公開されて ok になっています。
リポジトリの中だけを見る検査では、外部サービス側で起きることは見えません。外から実物を見る監査が別に要ります。そして、その監査も「待つだけの正常な状態」を正常と言える必要があります。
止まった場所6:外から締め出された
無人のクラウド環境から外部サービスに接続すると、人のブラウザからなら通るものが、機械からのアクセスとして止められることがあります。今回あったのはこの3つです。
| 相手 | 返ってきたもの | どうしたか |
|---|---|---|
| ある投稿サービスのログイン | 403「しばらくたってからもう一度お試しください」。24時間あけて再試行しても同じ | 回避しない。そのサービスへの自動投稿はやめ、仕組みを削除 |
| Docker Hub | 429(回数制限) | 同じイメージを別の公開ミラーから取得 |
| github.com(この実行環境から直接) | 403 | GitHub の操作は、用意されている連携の経路だけを使う |
1つ目は、判断の基準を決めておくことが大事でした。ボット判定をすり抜ける細工をすれば通るかもしれませんが、それは相手の防御を回避することです。アカウントを止められればすべてを失います。試行は4回まででやめ、再試行は時間をあけて1回だけ、それでもだめなら使わない、と決めて記録に残しました。無人のセッションは放っておくと同じことを何度も試すので、「やめる条件」を先に書いておく必要があります。
2つ目と3つ目は、環境の制約です。止まったときに「どこを通れば通るか」を次のセッションが知らないと、毎回同じところで止まります。回避の方法(どのミラーを使うか)は、手順書とスクリプトの中に書いておきます。
止まった場所7:状態が会話にあって、次のセッションに渡らなかった
何が起きるか
無人のセッションは毎回新しく始まり、前の会話を覚えていません。「前回こう決めた」「この作業は途中」が会話の中にしかないと、次のセッションは同じことを最初からやり直すか、逆のことをします。
足したもの
- 状態は全部リポジトリのファイルに書く。今の状態は
STATE.md、判断とその理由はDECISIONS.md、時系列の記録はLOG.md -
STATE.mdは 上書きで最新に保ち、100行以内。push 前の検査で行数を確かめ、超えたら止めます
lines=$(wc -l < STATE.md); [ "$lines" -le 100 ] || { echo "NG: STATE.md が ${lines} 行(100行以内に)" >&2; fail=1; }
$ (STATE.md に10行足して)bash scripts/check.sh
NG: STATE.md が 110 行(100行以内に)
AI は放っておくと追記を重ねます。追記が続くと、古い「次にやること」と新しい「次にやること」が並び、次のセッションが古い方を拾います。上限はそれを機械で止めるためのものです。
- 作業の最後に必ず「引き継ぎ」の手順(
STATE.mdの上書き、LOG.mdへの1行、push)を行う、と指示書に書いています
止まっていないのに、見えなくなったもの
止まる問題を一通り塞いだあとで、別の種類の問題が残りました。仕組みは動いているのに、大事なことが記録されていない 問題です。
- 費用の記録の抜け:作業の最後に、そのセッションの使用量(API 換算)を読んで記録する決まりにしていました。ところが、定例以外の臨時のセッションが増えた数日間、費用の記録がまったく残っていませんでした。別の AI セッションに経緯を渡さずに全体を点検させたところ、そこで指摘されて気づきました。定例の指示書にだけ書いた決まりは、定例以外の作業には効きません
- 誤報:上の本の「待ち」のように、待つだけの正常な状態を異常として知らせてしまうことがありました。誤報が続くと、本物の異常も読まれなくなります
どちらも「止まった」わけではないので、止まりを探す見張りでは見つかりません。今のところ、決まりを指示書ではなく仕組み(検査・監査)の側に寄せること、定期的に独立した目で点検させることで対処しています。
見張りを作って分かったこと
- 止まり方の種類だけ見張りが要る。失敗・許可待ち・全体停止・外部の上限は、それぞれ別の方法でしか見えない
- 一番外側の見張りは AI を使わない。AI の見張りは、AI と一緒に止まる
- 指示には「できなければどうするか」まで書く。「避けて」だけでは、避けられないときに待つ
-
定期実行は遅れる前提で、何度動いても同じ結果にする。予備の時刻、
concurrency、状態をリポジトリに書く - セッション同士は命令でつながない。依頼は材料として扱われる。設定の変更は、変更する本人が判断できる形で
- 見張りは「正常を正常と言える」ことも仕事。誤報は見張りを殺す
限界
- ここに書いたのは10日間・1つのリポジトリでの記録です。止まり方はこれで全部ではありません
- 止まり確認は2時間後なので、その2時間は失われます。もっと短くすると、見張りそのものの費用が増えます
- 指示書の決まり(許可が要る操作を避ける、費用を記録する)はお願いで、守られない回はありえます。守られたかどうかを機械で確かめられるものから、検査の側に移しています
- 一番外側の死活監視は「コミットがあるか」しか見ていません。中身のない作業が続いても、コミットさえあれば鳴りません
無人のセッションに読ませる CLAUDE.md の下書きは、質問に答えるだけで作れる無料ツールでも作れます:https://acerola-tools.acerola.workers.dev/?from=qiita-claude-code-unattended-stops
本記事は Zenn にも掲載しています:https://zenn.dev/acerola_jp/articles/claude-code-unattended-stops