はじめに
当社(合同会社ジョインクラス)は、記事公開・SNS配信・売上集計などの業務を launchd の17ジョブと Claude Code で回しています。自動化率は98%ほどです。
その運用で一番迷ったのは「待つ処理」の書き方でした。デプロイが終わるまで待つ、PRのCIが通るまで見張る、レートリミットが解けたら再実行する、といった処理です。
Claude Code の /loop には2つのモードがあります。
-
固定間隔:
/loop 5m /fooのように間隔を指定する -
セルフペーシング: 間隔を省略する。モデルが毎回
ScheduleWakeupで次の起床時刻を決める
この記事では、僕が実際に使っているスクリプトをもとに、2つのモードの使い分けの基準・手順・落とし穴・検証方法をまとめます。
結論:判断基準は「状態がどれくらいの速さで変わるか」
| 状況 | 推奨 | 理由 |
|---|---|---|
| 監視対象の周期が決まっている(毎時の集計、5分ごとのヘルスチェック) | 固定間隔 | 毎回同じ処理。モデルに間隔を判断させる意味がない |
| 終わる時刻が読めない外部処理(CI、デプロイ、Zennの反映) | セルフペーシング | 状況に合わせて「8分後に1回だけ見る」のように調整できる |
| ハーネスが完了を通知してくれる処理(バックグラウンドの Bash やサブエージェント) | どちらも使わない/長めのフォールバックだけ置く | 完了すると自動で再起動されるので、ポーリングは無駄 |
| 毎日決まった時刻に動かす、PCを閉じても続ける |
/loop ではなく launchd か /schedule
|
/loop はセッションが生きている間しか動かない |
3行目は特に見落としやすいです。run_in_background で起動したコマンドは、終わるとエージェントが自動で呼び戻されます。そこへ60秒ごとのポーリングを足しても、トークンが減るだけです。
1. 固定間隔:毎回同じチェックをするとき
当社のジョブ群は、すべての処理で common.sh を source しています。以下はそこから抜き出した部分です(実ファイルは .company/scripts/common.sh)。
#!/bin/bash
set -euo pipefail
export PATH="/Users/kyoagun/.nvm/versions/node/v22.17.0/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export HOME="/Users/kyoagun"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
COMPANY_DIR="$PROJECT_ROOT/.company"
LOG_DIR="$COMPANY_DIR/scripts/logs"
SCRIPT_NAME="$(basename "${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}" .sh)"
mkdir -p "$LOG_DIR"
on_error_notify() {
local exit_code=$?
local line_no=${1:-unknown}
log_error "スクリプト失敗: $SCRIPT_NAME (行:$line_no, 終了コード:$exit_code)"
if [ -n "${SLACK_WEBHOOK_URL:-}" ]; then
curl -sf -X POST -H "Content-Type: application/json" \
-d "$(jq -n --arg text ":x: [AI-CEO] $SCRIPT_NAME 失敗 (行:$line_no, コード:$exit_code)" '{text: $text}')" \
"$SLACK_WEBHOOK_URL" >/dev/null 2>&1 || true
fi
}
ジョブの失敗はここで Slack に通知されます。ただ作業中のセッションでは、「ログの末尾に ERROR が出ていないか」を一定間隔で見ているほうが早く気づけます。このように毎回同じことをするチェックは、固定間隔で十分です。
/loop 10m .company/scripts/logs/ 配下で直近10分に更新された *.log の末尾30行を確認し、ERROR または 終了コード が出ていたら原因と対象スクリプト名を1行で報告。何もなければ「異常なし」とだけ返す
ポイントは次の3つです。
- 出力を短くするよう指定する: 「何もなければ1行で」と書いておかないと、毎回長いレポートが返ってきて画面が埋まります
- 間隔はログの更新頻度に合わせる: 当社のジョブは最短でも数十分おきなので、1mにしても新しい情報はほぼありません
- 止めるときは明示する: セッションを閉じれば止まりますが、続けたまま止めたいときは「ループを止めて」と伝えます
2. セルフペーシング:いつ終わるか読めない外部処理
Zennには「pushは成功したのに記事が公開されない」ことがあります(レートリミットに当たったケースです)。当社では次のスクリプトで再デプロイしています(実ファイルは .company/scripts/retry-zenn-deploy.sh)。
#!/bin/bash
# Zennデプロイ失敗記事の再デプロイスクリプト
set -uo pipefail
REPO_DIR="/Users/kyoagun/workspace/zenn-content-company"
LOG_FILE="/Users/kyoagun/workspace/one-ceo/.company/scripts/logs/zenn-publish.log"
ARTICLE="articles/claude-code-morning-dashboard.md"
cd "$REPO_DIR"
echo "" >> "$ARTICLE" # 空行を足して差分を作る
git add "$ARTICLE"
git commit -m "fix: レートリミットで失敗した記事を再デプロイ (claude-code-morning-dashboard)"
git push origin main
# 同日の通常公開をスキップさせるロック
touch "/tmp/zenn-publish-$(date +%Y%m%d).lock"
echo "$(date): Retry deploy + lock created: claude-code-morning-dashboard.md" >> "$LOG_FILE"
# 1回限りのlaunchdジョブなので自分を削除
launchctl unload ~/Library/LaunchAgents/com.joinclass.retry-zenn-deploy.plist 2>/dev/null
rm -f ~/Library/LaunchAgents/com.joinclass.retry-zenn-deploy.plist
push のあとに公開されたかを確認する処理は、反映まで数十秒で終わることもあれば、十数分かかることもあります。固定5分だと早すぎて空振りが続くか、遅すぎて失敗の検知が遅れます。ここではセルフペーシングを使います。
/loop https://zenn.dev/joinclass/articles/claude-code-morning-dashboard を curl -s -o /dev/null -w '%{http_code}' で確認。200 になったらループを止めて報告。404 が30分以上続いたら retry-zenn-deploy.sh を1回だけ実行し、その後は間隔を広げて確認を続ける
セルフペーシングでは、モデルが毎回 ScheduleWakeup を呼んで次の起床時刻を決めます。たとえば次のように、状況に合わせて間隔が変わっていきます(イメージです)。
delaySeconds: 240 reason: "push直後。Zennの通常の反映時間に合わせて4分後に1回確認"
delaySeconds: 600 reason: "まだ404。レートリミットの可能性があるので間隔を広げる"
stop: true
セルフペーシングの落とし穴
- 短い間隔でポーリングしない: ハーネスが完了を把握している処理(バックグラウンドの Bash など)は、終わると自動で再起動されます。この場合はポーリングせず、1200秒以上のフォールバックだけを置きます
- 終了条件をプロンプトに書く: 「200 になったら止める」のような条件がないと、ループが続いて止まりません
- 副作用のある処理は回数を制限する: 「1回だけ」と書かないと再デプロイが繰り返され、Zennのレートリミット(1日1件)にまた引っかかります。当社では実際に複数回引っかかったことがあり、その結果、上のロックファイル方式を作りました
-
遅延の範囲は60〜3600秒: 1時間以上待つ処理は
/loopに向きません。launchd を使います
3. そもそも /loop を使わないケース
僕は以前、macOS の cron をそのまま使っていました。ところがフルディスクアクセスの権限がなかったため、cron ジョブがすべて動いていなかったことがあります。そこで launchd に全面移行しました。毎日決まった時刻に動かす処理や、セッションを閉じても続けたい処理は、今もすべて launchd です。
# 登録済みジョブと直近の終了コードを確認(2列目が0以外なら失敗)
launchctl list | grep com.joinclass
# 1回限りジョブが自分を削除できたか
ls ~/Library/LaunchAgents/ | grep retry-zenn-deploy || echo "削除済み"
使い分けをまとめると、次のようになります。
- launchd: 毎日決まった時刻に動かす処理、永続化が必要な処理
-
/loop固定間隔: 作業中だけ、一定周期で同じチェックを回したいとき -
/loopセルフペーシング: 終わる時刻が読めない外部の状態を、作業中に見張るとき
今日試すための手順
- 自分の自動化から「待っている処理」を1つ選ぶ(CI、デプロイ、ビルドなど)
- その処理の終わる時刻が決まっているか、毎回ばらつくかを確認する
- 決まっていれば
/loop 5m ...、ばらつくなら間隔を省略して実行する - プロンプトに 終了条件・出力量・副作用の回数上限 の3つを書く
- 数回動かしたら
reasonを見て、間隔の判断が妥当かを確認する
おわりに
当社が最初に作った自動化は、間隔をすべて固定にしていました。その結果、空振りのチェックに無駄なトークンを使っていました。「状態がどれくらいの速さで変わるか」でモードを選ぶようにしてからは、監視のコストと検知の遅れの両方が減っています。
launchd・Hooks・/loop を組み合わせた自動化の全体像は、拙著『Claude Code 全自動化バイブル』で解説しています。