自動化を設定したはずのスケジュールタスクが、気づくと途中で止まっている——そんな経験はないでしょうか。Cowork でスケジュールタスクを本格的に運用し始めると、最初のうちは「なぜ止まったのかわからない」という状況によく遭遇します。
私自身、複数サイトの記事自動生成タスクを個人開発で運用してきた中でいくつかのパターンを掴みましたので、体系的にまとめてみます。
この記事の後半で扱う内容は、机上の整理ではありません。この記事を書いている当日の実行でも、作業用リポジトリが他ユーザー所有になっていて書き込めないという障害に実際にぶつかりました。そのとき手元で取った終了コードとエラー文をそのまま載せています。
なぜスケジュールタスクは「黙って止まる」のか
スケジュールタスクのやっかいなところは、失敗しても通知が来ない場合があることです。手動実行なら画面で即座に確認できますが、スケジュール実行ではバックグラウンドで処理が走っているため、気づかないうちに途中終了しているケースがあります。
原因を大きく分けると3つのパターンに収まります。
許可ダイアログの表示による停止 が最も頻繁に起きます。Cowork のスケジュールタスクは、ユーザーが操作していないタイミングで実行されるため、確認ダイアログが出ると応答待ちのまま固まってしまいます。AskUserQuestion ツールや Read / Write / Edit ファイルツール、request_cowork_directory などが許可ダイアログをトリガーします。スケジュール実行のコンテキストでこれらを使うと、誰も「はい」を押せないまま処理が止まります。
ディスク不足(ENOSPC)による停止 も実運用でよく遭遇します。Claude Code や git clone を含む処理を定期実行していると、/tmp 領域が徐々に消費されます。特に npm パッケージのインストールが含まれていると、一度の実行で数百 MB が消費されることもあります。
git 操作のロック競合 も起きることがあります。前回の実行が中途半端に終わった場合、.git/index.lock ファイルが残って次回の実行時に fatal: Unable to create ... .git/index.lock エラーになるケースです。
この3つは、いずれもエラーとして表に出るぶん、まだ扱いやすい部類です。本当に手強いのは、エラーを出さずに成功したふりをして終わる失敗でした。それは記事の後半で扱います。
診断: どのパターンで止まったかを特定する
まず実行ログを確認します。Cowork のスケジュールタスクにはセッションログが残るので、最後に出力されたメッセージを確認してください。
# /tmp のディスク残量を確認(ENOSPC の判断基準)
df /tmp --output=avail -m | tail -1
# git ロックファイルの有無を確認
find /tmp/repos -name "*.lock" 2> /dev/null
# 作業ルートの所有者を確認(自分の uid と一致するか)
stat -c '%U:%G %a %n' /tmp/repos/ * 2> /dev/null
id -u
ログの最終行が AskUserQuestion 呼び出しや Read ツールの実行直後であれば、許可ダイアログ問題です。ENOSPC や No space left on device が含まれていれば、ディスク不足です。index.lock や fatal: Unable to create があれば、git ロック競合です。
最後の stat と id -u を並べているのには理由があります。実行ユーザーの uid は毎回同じとは限らず、前回のプロセスが別ユーザーで作ったディレクトリが残っていると、そこから先の書き込みが全て失敗するためです。次のセクションで、この所有者ずれが厄介な形で表面化する様子を見ていきます。
復旧方法: パターン別の対処
許可ダイアログ問題
SKILL.md(スキルファイル)を開いて、以下のツールの使用箇所を確認します。
禁止ツール(スケジュール実行では使用不可):
- AskUserQuestion
- Read / Write / Edit ← ファイル操作は bash の cat/sed に置き換える
- request_cowork_directory
ファイルの読み書きをすべて bash コマンドに書き換えるのが根本的な解決策です。
# ❌ 禁止: Read ツールでの読み取り
# ✅ 正解: bash での読み取り
cat /path/to/file.md
# ❌ 禁止: Write ツールでのファイル書き込み
# ✅ 正解: bash での書き込み
cat << 'EOF' > /path/to/output.mdx
記事コンテンツ
EOF
# ❌ 禁止: Edit ツールでの編集
# ✅ 正解: sed での部分置換
sed -i 's/old_text/new_text/' /path/to/file
なお、SKILL.md 自体の内容は問題ないが、それを呼び出すスケジュールタスク本体のプロンプトに問題がある場合も多いです。タスク本体に「確認不要で全工程を自律実行する」「AskUserQuestion は一切行わない」という明記があるか確認してください。
ディスク不足(ENOSPC)
# 段階的なクリーンアップ
# ① 他サイトのリポジトリを削除
for DIR in /tmp/repos/* ; do
[ -d " $DIR " ] && rm -rf " $DIR " && echo "削除: $DIR "
done
# ② npm キャッシュを削除
npm cache clean --force 2> /dev/null
# ③ .next ビルドキャッシュを削除
find /tmp -name ".next" -type d 2> /dev/null -exec rm -rf {} + 2> /dev/null
# 残量確認
df /tmp --output=avail -m | tail -1
根本的な解決策として、スケジュールタスクのワークフローから npm install を完全に除去することをお勧めします。たとえば Next.js + Cloudflare Workers の構成では、MDX ファイルをコミット・プッシュすれば CI がビルドを担当するため、ローカルの npm install は不要です。処理フローを「clone → MDX 書き込み → push」だけに絞ると、1 回の実行で書き込まれるのは実測で 41MB でした(うち .git が 11MB、--depth 1 の shallow clone、所要 2.8 秒)。npm install を含む構成との差は、桁がひとつ変わります。
git ロック競合
WORK = "/tmp/repos/your-repo"
# ① ロックファイルを削除
rm -f " $WORK /.git/index.lock"
# ② リポジトリの状態をリセット
cd " $WORK "
git reset --hard HEAD
git clean -fd
# ③ 再度 pull
git pull --rebase origin main
それでも解決しない場合は、リポジトリを削除して再クローンします。
rm -rf " $WORK "
git clone --depth 1 "https://${ GITHUB_TOKEN }@github.com/username/repo.git" " $WORK "
safe.directory を足すと、失敗は消えずに「遅くなる」
ここからが、この記事でいちばんお伝えしたい部分です。
作業用リポジトリを使い回していると、ある朝こういうエラーに出会います。
fatal: detected dubious ownership in repository at '/tmp/repos/claudelab.net'
To add an exception for this directory, call:
git config --global --add safe.directory /tmp/repos/claudelab.net
git が親切に対処法まで書いてくれているので、素直に safe.directory を足したくなります。私も最初はそうしました。そして、そこから先で長く悩むことになりました。
まず状況を確認します。手元の環境は次の通りでした。
$ git --version
git version 2.34.1
$ stat -c '%U:%G %a' /tmp/repos/claudelab.net
nobody:nogroup 755
$ echo "dir_uid=$( stat -c '%u' /tmp/repos/claudelab.net) my_uid=$( id -u )"
dir_uid = 65534 my_uid = 1222
ディレクトリは uid 65534(nobody)の所有で、パーミッションは 755。実行している自分は uid 1222 です。つまり書き込み権限は最初からありません。
ここで safe.directory を足すと何が起きるか。実測した結果がこちらです。
コマンド safe.directory 適用後の結果
git status --porcelainexit 0(成功)
git log --oneline -1exit 0(成功)
git rev-parse HEADexit 0(成功)
git fetch origin mainexit 255
git pull --rebase origin mainerror: cannot open .git/FETCH_HEAD: Permission denied
MDX ファイルへの書き込み Permission denied
読み取り系のコマンドは、3つとも exit 0 で通ってしまいます。
safe.directory が無効化したのは所有者チェックだけ であって、ファイルのパーミッションには何も作用していないからです。読むだけの操作は 755 の r-x で足りるので成功し、書き込みが必要になった瞬間に落ちます。
これが厄介なのは、失敗が消えるのではなく、発覚が遅くなる 点です。
Step 0 のリポジトリ準備で git status を叩いて健全性を確認する設計だと、ここは通過してしまいます
実際に落ちるのは、記事を書き終えてコミットしようとする終盤です
そこまでに費やした処理時間は、すべて捨てることになります
さらに悪い分岐もあります。git pull の終了コードを見ずに次へ進む設計だと、pull が失敗しているのに数日前の内容のまま 処理が続きます。エラーは出ているのにログの流れは正常に見えるので、翌朝ログを眺めても異常に気づけません。
ここから引き出した運用上の判断はシンプルです。スケジュールタスクでは safe.directory に手を伸ばさない 、という一点に尽きます。
例外を足して動かし続けるより、書ける場所へ移るほうが結果的に早い。私はこの割り切りを採っています。
safe.directory が適切なのは「ファイルは自分のものなのに、git がそう判断できない」状況です。マウントされたボリュームや、sudo を挟んだ結果として所有者情報がずれる場合がこれにあたります。
一方、いま起きているのは「ファイルが本当に自分のものではない」状況です。この場合に必要なのは例外の追加ではなく、書ける場所へ移ること です。所有者が自分でないディレクトリを見つけたら、直すのではなく捨てて、書ける場所に取り直す。これが最も短い復旧経路でした。
もう一つの落とし穴: 古いクローンと「成功したように見える失敗」
エラーを出さずに終わる失敗を、あと3つ挙げます。どれも実運用で実際に踏んだものです。
ひとつは、使い回しているクローンが静かに古くなる問題です。/tmp/repos に置いた永続リポジトリは数日も経つと内容が陳腐化します。git pull は通っているように見えるのに、実際にはローカルの記事一覧が数日前のままで、すでに改善済みの記事をもう一度触ってしまう——複数サイトを並行運用していると、こうした取り違えが起こりがちです。候補選定や記事の状態確認は、使い回しのクローンではなく、その実行で取り直した最新の内容に対して行うのが安全です。
# 古いクローンを避け、書ける場所で最新を取り直す
WORK = " $HOME /repos/your-repo" # /tmp が他 uid 所有なら $HOME 配下へ
if [ -d " $WORK /.git" ] && [ -w " $WORK /.git" ]; then
cd " $WORK " && git fetch origin main && git reset --hard origin/main
else
rm -rf " $WORK "
git clone --depth 1 "https://${ GITHUB_TOKEN }@github.com/username/repo.git" " $WORK "
fi
ふたつめは、コミットが無言で失敗しているのに push が「成功」と表示される問題です。クローン直後は git のユーザー情報が未設定のことがあり、その状態で git commit を実行するとコミットが作られないまま終わります。続く git push は「送るものが無い」だけなので成功扱いになり、ログ上は何も問題が無いように見えてしまうのです。コミット前に必ず identity を渡し、最後にローカルとリモートのコミットハッシュが一致したことで成否を判定します。
git -c user.email="you@example.com" -c user.name="Your Name" commit -m "..."
git push origin main
# 成否は「表示」ではなくハッシュ一致で確認する
LOCAL = $( git rev-parse HEAD )
REMOTE = $( git rev-parse origin/main )
[ " $LOCAL " = " $REMOTE " ] && echo "✅ 反映済み" || echo "⛔ 未反映 — 再送信が必要"
みっつめは、シェルの書き方そのものが失敗を隠してしまう問題です。エラー出力を読みやすくしようとして head や tail にパイプでつなぐと、$? はパイプの最後の要素 の終了コードを返します。つまり、コマンドが落ちていても $? は 0 になります。
先ほどの権限エラーで、実際に測ってみました。
$ touch /tmp/repos/claudelab.net/.probe 2>&1 | head -1 > /dev/null
$ echo " $? ${ PIPESTATUS [0]}"
0 1 # ← $? は 0。実際に落ちたのは PIPESTATUS[0]=1
$ touch /tmp/repos/claudelab.net/.probe 2> /dev/null
$ echo " $? "
1 # ← パイプを外せば正しく 1
set -e を書いていても救われません。パイプライン全体の終了コードは 0 と見なされるため、set -e は発火せず、後続の処理がそのまま走ります。
対処は 2 つです。判定に使うコマンドはパイプにつながない。つなぐ必要があるなら set -o pipefail を宣言するか、${PIPESTATUS[0]} を見る。
set -o pipefail
touch " $WORK /.probe" 2>&1 | head -1 > /dev/null
echo " $? " # → 1(pipefail 有効時は正しく伝播する)
ログの日付にも同じ「静かな失敗」が潜んでいます。ログファイル名を素の date で作ると、実行環境のタイムスタンプが UTC のことがあり、日本時間ではまだ当日でも前日のファイル名が選ばれて、前日のログを上書きしてしまうことがありました。日付は明示的にタイムゾーンを指定して生成しておくと安心です。
D = "$( TZ = Asia/Tokyo date +%Y-%m-%d)" # 素の date は UTC で前日を指すことがある
Cowork の自動運用を複数サイトに広げていくと、この種の「静かな失敗」の総量が増えていきます。運用全体の設計については Cowork で4サイト×600記事を自動運用してみた にまとめています。
Step 0 に置くプリフライト — 書ける場所を先に確定させる
ここまでの内容を踏まえると、対処すべき順序が見えてきます。
これまで私は「clone してから、失敗したら直す」という順序で書いていました。この順序だと、権限や残量の問題が clone のあと、あるいはもっと後ろで露見します。
順序を入れ替えます。書ける場所を先に確定させてから、そこへ clone する 。プリフライトが返すのは真偽値ではなく、書ける作業ルートのパスそのものです。
#!/usr/bin/env bash
set -uo pipefail
MIN_FREE_MB = 500
# 書ける作業ルートを探して、そのパスを返す
# 候補を順に試し、最初に条件を満たしたものを採用する
pick_work_root () {
local root avail
for root in /tmp/repos " $HOME /repos" ; do
mkdir -p " $root " 2> /dev/null || continue
# ① 実際に書けるか(-w は所有者・パーミッション・マウント状態を一度に見る)
[ -w " $root " ] || { echo "skip $root : not writable" >&2 ; continue ; }
# ② 残量が閾値を超えているか
avail = $( df -m " $root " --output=avail 2> /dev/null | tail -1 | tr -d ' ' )
if [ "${ avail :- 0 }" -lt " $MIN_FREE_MB " ]; then
echo "skip $root : ${ avail }MB < ${ MIN_FREE_MB }MB" >&2
continue
fi
echo " $root " # ← 採用したパスを標準出力へ
return 0
done
return 1
}
WORK_ROOT = "$( pick_work_root )" || {
echo "E_PERM_ROOT: 書き込める作業ルートがありません"
exit 20
}
echo "work_root= $WORK_ROOT (avail=$( df -m " $WORK_ROOT " --output=avail | tail -1 | tr -d ' ')MB)"
既存のクローンを再利用するかどうかも、同じ考え方で決めます。「あるかどうか」ではなく「書けるかどうか」で分岐させます。
NAME = "your-repo"
WORK = " $WORK_ROOT / $NAME "
REPO_URL = "https://${ GITHUB_TOKEN }@github.com/username/${ NAME }.git"
if [ -d " $WORK /.git" ] && [ -w " $WORK /.git" ]; then
# 再利用できる: 最新へ同期(pull ではなく fetch + reset で確実に)
cd " $WORK "
if ! git fetch origin main 2> /dev/null ; then
echo "fetch 失敗 — 作り直します" >&2
rm -rf " $WORK " 2> /dev/null
git clone --depth 1 -q " $REPO_URL " " $WORK " || exit 21
else
git reset --hard origin/main --quiet
fi
else
# 再利用できない: 消せるなら消して、取り直す
rm -rf " $WORK " 2> /dev/null
git clone --depth 1 -q " $REPO_URL " " $WORK " || exit 21
fi
cd " $WORK " || exit 21
echo "head=$( git rev-parse --short HEAD)"
このコードで意識した点を3つ挙げます。
1. 判定は -w に任せる。 所有者 uid を自分で比較したくなりますが、これは筋が悪い選択でした。stat の uid 比較は「自分が所有者なら書ける」という前提に立っていますが、実際にはグループ書き込み権のあるディレクトリや、読み取り専用でマウントされた領域では答えを間違えます。速度差も無視できません。1,000 回の判定にかかった時間は、test -w が 11ms、stat による uid 比較が 3,748ms でした。stat はその都度プロセスを起動するので、340 倍の差がつきます。正確さでも速度でも -w が優ります。
2. -w だけでは足りない場面がある。 .git は書けても作業ツリーが書けない、という組み合わせは起こり得ます。判定は、実際に書き込むパスに対して行うのが確実です。
3. pull ではなく fetch + reset --hard を使う。 pull は merge や rebase の失敗経路を抱えており、コンフリクトが出ると対話待ちになりかねません。ローカルの変更を保持する必要がない使い捨てのクローンでは、reset --hard origin/main のほうが結果が読めます。
閾値の 500MB には根拠を持たせておきます。shallow clone 1 本が 41MB、.git が 11MB でしたから、記事の書き込みまで含めても 1 サイトあたり 50MB 前後です。手元の /tmp は空き 2,990MB(全体 9,741MB)でしたので、4 サイト同時でも 200MB 程度。500MB は約 2 倍の余裕を見た数字です。npm install を含むワークフローなら、この見積もりは根本から変わります。
理由コードでログを構造化する
ここまでの障害には、それぞれ固有の復旧手順がありました。ログに「失敗しました」とだけ書いても、翌朝の自分は同じ調査をやり直すことになります。
失敗を理由コード に落とし込んでおくと、翌朝の判断が数秒で済むようになります。
コード 意味 ログ最終行の特徴 自動復旧
E_PERM_ROOT作業ルートが他 uid 所有 dubious ownership / Permission denied可 — 書けるルートへ切替
E_DISK残量不足 No space left on device可 — 段階的クリーンアップ
E_LOCKindex.lock 残存fatal: Unable to create ... index.lock可 — lock 削除 → reset
E_IDENTITYgit identity 未設定 コミットが作られず無言で通過 可 — -c で明示的に渡す
E_STALEfetch 失敗を無視して継続 エラー無し (ここが最も危険)可 — 終了コードを検査
E_APPROVAL許可ダイアログ待ち ファイルツール実行の直後で無音終了 不可 — SKILL.md の修正が必要
自動復旧の可否で列を分けているのが要点です。E_APPROVAL だけは人が SKILL.md を直すまで何度でも再発します。ここを他と混ぜてしまうと、「毎日失敗しているが、自動復旧しているから大丈夫」という誤った安心が生まれます。
ログは1行1レコードの key=value 形式にしておくと、後から機械的に読めます。
log_line () {
local result = " $1 " reason = " ${2 :- } " detail = " ${3 :- } "
printf '%s site=%s result=%s reason=%s detail="%s"\n' \
"$( TZ = Asia/Tokyo date +%Y-%m-%dT%H:%M:%S%z)" \
"${ SITE :- unknown }" " $result " "${ reason :- NONE }" " $detail " \
>> "${ LOG_DIR }/$( TZ = Asia/Tokyo date +%Y-%m-%d).txt"
}
# 使い方
log_line SUCCESS
log_line FAILED E_PERM_ROOT "uid 65534 != $( id -u ) at /tmp/repos"
log_line SKIPPED E_DISK "avail=180MB"
30 日分をまとめて眺めるときは、これで足ります。
# 理由コード別の発生数(多い順)
grep -h -o 'reason=[A-Z_]*' "${ LOG_DIR }"/2026-0[67]- * .txt \
| sort | uniq -c | sort -rn
# いつから失敗し続けているか(直近の SUCCESS 以降を数える)
grep -l 'result=FAILED' "${ LOG_DIR }"/ * .txt | sort | tail -14
私の場合、この集計を始めてから対処の優先順位がはっきりしました。件数の多い理由コードは大抵すでに自動復旧できていて、実際に手を入れる価値があったのは、件数は少ないが自動復旧できない E_APPROVAL のほうでした。数が多い順に直していたら、順序を間違えていたと思います。
MCP コネクタ側の健康状態も同じ考え方で見える化できます。そちらは 夜間に回すMCPコネクタの健康状態を自前で見える化する で扱っています。
再発防止: SKILL.md に組み込む防衛コード
これらを SKILL.md の Step 0 にまとめると、次の形になります。順序が要点です。書ける場所の確定が最初、clone はそのあとです。
set -uo pipefail
# ① 前回の残骸をクリーンアップ(書ける場合のみ)
[ -w "${ WORK :-/ nonexistent }/.git" ] && rm -f " $WORK /.git/index.lock" 2> /dev/null
# ② 書ける作業ルートを確定(見つからなければ E_PERM_ROOT で中断)
WORK_ROOT = "$( pick_work_root )" || { log_line FAILED E_PERM_ROOT "no writable root" ; exit 20 ; }
# ③ 残量チェック(閾値未満なら他サイトのリポジトリから段階的に解放)
FREE_MB = $( df -m " $WORK_ROOT " --output=avail | tail -1 | tr -d ' ' )
if [ "${ FREE_MB :- 0 }" -lt 500 ]; then
for OTHER in " $WORK_ROOT "/* ; do
[ " $OTHER " != " $WORK " ] && rm -rf " $OTHER " 2> /dev/null
done
FREE_MB = $( df -m " $WORK_ROOT " --output=avail | tail -1 | tr -d ' ' )
[ "${ FREE_MB :- 0 }" -lt 500 ] && { log_line FAILED E_DISK "avail=${ FREE_MB }MB" ; exit 22 ; }
fi
# ④ 書けるなら同期、書けないなら取り直す
# ⑤ 終了コードは必ず検査する(パイプにつながない)
「失敗しても次回起動時に自動回復できる構造」を最初から組み込んでおくのが、長期運用での鉄則です。そして自動回復できない種類の失敗——許可ダイアログだけは、コードではなく SKILL.md の書き方で潰すしかありません。
複数の MCP を組み合わせた自動化全体の設計は Cowork × 複数MCPオーケストレーション に、/tmp 権限とディスク不足に絞った初期切り分けは Cowork スケジュールタスクで git clone が失敗するとき にまとめてあります。
本番投入前のチェックリスト
自動運用に載せる前に、次の8点を確認しています。
SKILL.md とスケジュールタスク本体の両方から Read / Write / Edit / AskUserQuestion を排除したか
作業ルートの決定が clone より前 にあるか
書ける・書けないの判定を -w で、実際に書き込むパスに対して行っているか
safe.directory に頼っていないか(他 uid 所有なら、直さずに場所を変える)
git pull ではなく fetch + reset --hard を使い、終了コードを検査しているか
判定に使うコマンドをパイプにつないでいないか(つなぐなら set -o pipefail)
commit 前に -c user.email / -c user.name を渡し、成否をハッシュ一致で判定しているか
ログの日付を TZ=Asia/Tokyo date で生成しているか
このうち 2・4・6 は、今回の実測で順序と判断を入れ替えた項目です。どれも、動いているうちは何の症状も出ません。症状が出るのは、たいてい自分が見ていない時間帯でした。
まずは手元の SKILL.md を開いて、Step 0 の最初の 10 行だけ読んでみてください。clone より前に「書ける場所か」を確かめる行があるかどうか。無ければ、そこが次の障害の発生地点になります。
私自身まだ運用の途中で気づくことばかりですが、同じところでつまずく方の遠回りが少しでも減れば嬉しく思います。