無人で回しているスケジュールタスクが「成功」とログに残しているのに、出来上がったものが一つ前の世界線のまま、ということがあります。私自身、個人開発で4つのAIブログ(Dolice Labs)をCoworkのスケジュールタスクで自動運用していて、ある朝のログでひやりとしました。「この記事はもう加筆済みか」を手元のクローンで確かめてから作業に入ったのですが、その手元のクローンが3日前のもので、実際にはすでにリモート側で別のランが同じ記事を更新し終えていたのです。あと一歩で、同じ記事を二重に書き換えるところでした。
エラーは一つも出ていません。git clone も成功し、ファイルも読めて、品質ゲートも通る。それでも判断の土台がずれていました。この種の事故は「失敗」ではなく「古い真実に基づく正しい処理」として起きるので、例外ハンドリングでは捕まりません。ここでは、永続クローンを「キャッシュ」として正しく扱い、意思決定の前に鮮度を契約として確かめる方法をまとめます。
なぜ永続クローンは黙ってずれるのか
速度のために、多くの自動化は作業リポジトリを毎回 clone し直さず、/tmp/repos/{site} のような場所に残して git pull --rebase で差分更新します。これは正しい最適化です。問題は、その永続クローンが「いつの時点のリモートか」を、後続の処理が暗黙に「最新」と信じてしまうことにあります。ずれは主に3経路で入ります。
1つ目は、別のランがすでに前進させているケースです。複数のスケジュール枠(朝の生成・昼の加筆・夜の復旧)が同じリポジトリを触る構成だと、自分が最後に pull してから他のランが push しています。手元の木はその push を知りません。
2つ目は、所有者が変わって pull 自体が静かに失敗するケースです。CoworkのVMではセッションをまたぐと /tmp/repos/ が nobody:nogroup 所有のまま残ることがあり、git pull も書き込みも弾かれます。リカバリで || true を付けていると、失敗したのに処理が前に進み、結果として古い木で判断します。
3つ目は、rebase が中途半端に終わっているケースです。前回のランが競合や中断で rebase を完了できず、デタッチドな状態や中途コミットを残していると、git log は一見もっともらしいのに最新ではありません。
いずれも共通するのは、読めるけれど古い という点です。だからこそ「読めたか」ではなく「最新と一致しているか」を確かめる必要があります。
stale な木の上での判断は、なぜ静かに壊れるのか
無人タスクが手元の木を読むのは、たいてい「処理」より前の「判断」のためです。たとえば次のような問いです。
判断の問い stale な木だと起きること
この slug はもう存在するか リモートで追加済みの記事を「無い」と誤認し、重複 slug を作る
この記事はもう加筆・修正したか すでに直っているのに「未対応」と見て二重に書き換える
日英の本数は一致しているか 古いカウントで一致と判断し、実際の不一致を見逃す
直近で同じ題材を出したか 古いログ・古い記事一覧で「未出」と誤判定し、題材が被る
判断が誤ると、その後の処理がどれだけ正しく動いても結果は壊れます。しかも壊れ方が「正常終了」なので、ログには Status: SUCCESS と残ります。私が肝に銘じているのは、検証されていない木の上で下した判断は、たとえ結論が正しく見えても無効として扱う という原則です。判断の前に木の鮮度を保証する。これを順序の問題として設計します。
鮮度を1秒で確かめるプリフライト
重い git fetch は要りません。リモートの参照だけを引く git ls-remote で、ブランチ先端のSHAを取り出し、手元のSHAと突き合わせれば十分です。あわせて、そもそも書き込めるのか(所有者・権限)も確かめます。
# preflight_clone_fresh: 作業ツリーが「書き込める」かつ「リモート最新と一致」かを返す
# 戻り値: 0=fresh / 10=stale(要pull) / 20=unwritable / 30=missing
preflight_clone_fresh () {
local work = " $1 " remote_url = " $2 " branch = " ${3 :- main } "
# (a) クローンが存在するか
[ -d " $work /.git" ] || return 30
# (b) .git が書き込めるか(所有者 nobody 化・権限不足を検知)
if ! ( touch " $work /.git/.wtest" 2> /dev/null && rm -f " $work /.git/.wtest" ); then
return 20
fi
# (c) ローカル HEAD とリモート先端 SHA を突き合わせ
local local_sha remote_sha
local_sha = "$( git -C " $work " rev-parse HEAD 2> /dev/null)"
remote_sha = "$( git ls-remote " $remote_url " "refs/heads/ $branch " 2> /dev/null | awk '{print $1}')"
# リモートが引けない場合は「不明」を stale 扱いにして安全側に倒す
[ -z " $remote_sha " ] && return 10
[ -z " $local_sha " ] && return 10
[ " $local_sha " = " $remote_sha " ] && return 0
return 10
}
ポイントは、リモートSHAが引けない・ローカルSHAが取れないといった「不明」を、わざと stale 側(要同期)に倒していることです。鮮度が確認できないものを「最新」と楽観するのは、まさに今回の事故の入口でした。確証がないときは前進しない、というのが安全側の倒し方です。
検知したら自己修復する判断フロー
プリフライトの戻り値に応じて、追いつくか、作り直すかを分けます。pull --rebase で追いつければ速い。書き込めない・存在しない・追いつけないなら、$HOME 配下に fresh clone して土台を作り直します(/tmp の所有者問題を回避できます)。
ensure_fresh_clone () {
local work = " $1 " remote_url = " $2 " branch = " ${3 :- main } "
preflight_clone_fresh " $work " " $remote_url " " $branch "
local code = $?
case " $code " in
0 )
echo "✅ fresh(local==remote): $work "
;;
10 )
echo "↻ stale 検知 — pull --rebase で追いつきます"
if git -C " $work " pull --rebase origin " $branch " > /dev/null 2>&1 ; then
# 追いついた後にもう一度突き合わせて確証を取る
preflight_clone_fresh " $work " " $remote_url " " $branch " \
&& echo "✅ 同期完了" \
|| { echo "⚠️ pull後も不一致 — 再クローンへ" ; reclone_fresh " $work " " $remote_url " " $branch " ; }
else
echo "⚠️ pull 失敗 — 再クローンへ"
reclone_fresh " $work " " $remote_url " " $branch "
fi
;;
20 | 30 )
echo "⚠️ 書き込み不可/不在 — \$ HOME へ fresh clone"
reclone_fresh " $work " " $remote_url " " $branch "
;;
esac
}
reclone_fresh () {
local stale = " $1 " remote_url = " $2 " branch = " ${3 :- main } "
local fresh = " $HOME /repos/$( basename " $stale ")"
rm -rf " $fresh " 2> /dev/null
git clone --depth 1 --branch " $branch " " $remote_url " " $fresh " > /dev/null 2>&1
git -C " $fresh " config user.email "you@example.com"
git -C " $fresh " config user.name "you"
# 以降の処理はこの fresh を作業ツリーとして使う
export WORK = " $fresh "
echo "✅ fresh clone 完了: $fresh "
}
reclone_fresh が WORK を上書きして export しているのは、この時点以降の「判断読み」も「書き込み」も、必ず検証済みの木に対して行わせる ためです。古い WORK をうっかり参照し続けると、せっかくのプリフライトが台無しになります。土台を差し替えたら、変数も差し替える。地味ですが、ここを徹底しないと事故は再発します。
Before / After:判断の前に1行入れるだけ
これまでのタスク冒頭は、こう書いていました。
# Before: 永続クローンを「最新」と暗黙に信じている
WORK = "/tmp/repos/claudelab.net"
cd " $WORK " && git pull --rebase origin main || true # 失敗しても進む
# ここで「この slug は存在するか」を判断 ← 古い木かもしれない
|| true が曲者で、所有者問題で pull が失敗しても処理が前に進み、その後の slug 判定や本数カウントが古い木の上で行われます。プリフライトを噛ませると、こうなります。
# After: 判断の前に鮮度を契約として保証する
WORK = "/tmp/repos/claudelab.net"
REMOTE = "https://github.com/you/claudelab.net.git"
ensure_fresh_clone " $WORK " " $REMOTE " main # 必要なら WORK を fresh に差し替える
cd " $WORK "
# ここからの判断読みは、検証済みの木に対してだけ行われる
JA = $( find content/articles/ja -name '*.mdx' | wc -l )
EN = $( find content/articles/en -name '*.mdx' | wc -l )
差分はわずか1行ですが、意味は大きく変わります。「読めたから最新」という暗黙の仮定を、「最新だと確かめたから読む」という明示の契約に置き換えています。
どこまでやるか — コストと安全のバランス
毎回 fresh clone すれば確実ですが、リポジトリが大きいと体感で2〜3倍遅くなり、ディスクも食います。だから「速い永続クローン+安い鮮度検証」の組み合わせが現実的です。git ls-remote は参照を1回引くだけなので数百ミリ秒で終わり、書き込みプローブも一瞬です。重い再クローン(数十秒)は、本当に必要なとき(書き込み不可・追いつけない)にだけ走ります。私はこの非対称さ——常時は軽く、たまに重く——を推奨します。
判断の重さで線を引くのも有効です。私が実際にタスクへ落とし込んでいる優先順は、こうです。
公開物を壊す判断(slug の重複・日英本数の一致)の前では、必ずプリフライトを通す
すでに直したか/すでに出した題材かといった「二重作業に直結する判断」の前でも通す
ログ整形のように壊れても害の小さい処理では省く
同じ設計は、AdMob の収益レポートを毎朝集計する別タスクにも入れています。参照する数字の鮮度を取り違えると、その日の判断がまるごとずれるからです。すべてを最大強度で守るより、壊れると痛いところに検証を寄せるほうが、長く回す運用では息切れしません。
最後に一つだけ、明日からできることを挙げるなら——いま動かしている無人タスクの冒頭に、local HEAD == remote HEAD の突き合わせを1行だけ足してみてください。私自身、この1行を入れてから「成功と書かれているのに中身が古い」という種類の不安がかなり減りました。共に、静かな事故を一つずつ潰していけたらと思っています。