無人で回しているパイプラインのログを見ていて、手が止まったことがあります。前の日に直したはずの箇所を、エージェントがもう一度、まったく同じ意図で直そうとしていたのです。差分は出ているのにリモートには何も増えない。失敗もしていない。ただ、すでに片付いた仕事をやり直そうとしていました。
原因は地味でした。作業を速くするために /tmp に置いていた浅いクローンが、数日かけて静かに古くなっていたのです。エージェントはその古い木を読んで「まだ直っていない」と判断し、淡々と二度目の修正を始めていました。
個人開発で複数のリポジトリを毎日決まった時刻に自動で触っていると、この「黙って古くなるクローン」は思った以上に厄介な相手になります。今日はこの一件を題材に、陳腐化を数値で捕まえて、判断の前にだけ取り直す設計を整理してみます。
もう直したはずの場所を、もう一度直そうとした
その日のエージェントは、いつものように永続化していたクローンを再利用していました。git pull --rebase は走っていて、ログ上はエラーもありません。それでも、生成しようとしていた成果物は前日に公開済みのものとほとんど同じでした。
手元で確かめると、原因はすぐに見えました。永続クローン側の記事数は 668、リモートを取り直すと 671。3 本ぶん、つまり別のセッションが先に追加した成果がローカルには反映されていなかったのです。HEAD のコミットも食い違っていました。エージェントは「671 のうち 3 本が新しい」という事実を知らないまま、668 の世界で「まだ無い」と判断していたわけです。
私自身、最初は pull を信じきっていたので、しばらく原因を疑う場所を間違えました。書き込みの権限を疑い、ネットワークを疑い、最後にようやく「読んでいる木そのものが古い」という当たり前のところに戻ってきました。
「書けない」と「古い」は、まったく別の故障です
この手の不具合を切り分けるとき、最初に分けておきたいのが障害の種類です。見た目は似ていても、効く対策がまるで違います。
症状 典型的な原因 効く対策
push しても何も増えない(commit は成功表示) git identity 未設定・index.lock 残留・空コミット commit 前に user.email/name を設定し、成否は local==remote の SHA 比較で判定
書き込みで Permission denied 作業ディレクトリの所有者違い・ディスク満杯 書き込めるパスへフォールバック、容量確保
差分は出るのに「やるべきことが無い」判断になる 読んでいるクローンが古い 判断の前にリモートとの遅れを測り、必要なら取り直す
今回の一件は、いちばん下の行です。書き込みは正常で、権限も問題ない。壊れていたのは「読み取りの鮮度」でした。git push が成功と言うのに何も増えない 系の対処や、所有者違いのフォールバックをいくら積み上げても、この故障には届きません。だからこそ、独立した一つの故障として扱う価値があります。
なぜ浅い永続クローンは黙って古くなるのか
速度のために --depth 1 の浅いクローンを使い回す運用には、はっきりした利点があります。毎回フルクローンするより圧倒的に速く、ディスクも軽い。私のクローンも node_modules を持たない構成で 30MB ほどに収まっていて、再利用したくなる気持ちは自然です。
問題は、その浅いクローンが「最新だと錯覚しやすい」ことです。古くなる経路はいくつもあります。
別のセッション・別のマシンが先に push している。 自動運用を分散していると、同じリポジトリに複数の書き手がいます。自分が眠っている間にリモートは進みます。
pull が走らなかった、あるいは握りつぶされた。 リベース衝突やネットワークの瞬断で pull が中途半端に終わっても、後続の処理は何事もなく走り続けることがあります。
浅い履歴ゆえに pull の挙動が読みにくい。 --depth 1 のローカルは履歴をほとんど持たないため、リベースが期待通りに進まない場面が増えます。
どれも派手なエラーを出しません。だから「黙って古くなる」のです。そして無人運用では、その沈黙を疑う人間がその場にいません。
陳腐化を数値で見る — ローカル HEAD と リモート HEAD を突き合わせる
対策の核心は、「pull が成功したかどうか」ではなく「いま読んでいる木が、リモートから何コミット遅れているか」を数値で出すことです。ネットワーク越しに参照だけ取ってくる git ls-remote を使えば、フェッチせずに先頭コミットを確認できます。
まず、避けたかった素朴な書き方です。
# Before: pull が成功したと信じて、そのまま判断に進む
cd " $WORK "
git pull --rebase origin main # 失敗しても後続は止まらないことがある
# ここで content/ を読んで「まだ無い」と判断 → 古い木で誤判断
次に、判断の前に遅れを測る書き方です。フェッチ前にリモート先頭を取り、ローカルと突き合わせます。
# After: 判断の前に、リモートとの遅れをコミット数で出す
cd " $WORK "
LOCAL = $( git rev-parse HEAD )
REMOTE = $( git ls-remote origin -h refs/heads/main | cut -f1 )
if [ " $LOCAL " != " $REMOTE " ]; then
# どれだけ遅れているかを数える(浅い履歴では取得できないこともある)
git fetch --depth 50 origin main 2> /dev/null
BEHIND = $( git rev-list --count "HEAD.. $REMOTE " 2> /dev/null || echo "unknown" )
echo "stale: local= $LOCAL remote= $REMOTE behind= $BEHIND "
fi
ここで大事なのは、ls-remote が作業ツリーを一切変えない 点です。読み取りの鮮度を、読み取りの判断を汚さずに測れます。LOCAL != REMOTE が出た時点で、いまの木は判断の土台として信用できない、と機械的に分かります。
取り直すか、追従するか — 判断の分かれ目
遅れが分かったら、次は「どう追いつくか」です。ここは一律ではなく、その読み取りが何を駆動するかで選びます。
状況 選択 理由
読み取りが「生成するか否か」の判断を駆動する 新規に取り直す(fresh clone) 浅い履歴のリベースより、取り直しのほうが結果が決定的で読みやすい
遅れが小さく、書き込みも単純な追記のみ git fetch && reset --hard origin/main取り直しコストを払わずに先頭へ揃えられる
同じパスを複数の書き手が触る 取り直し+生成前の実体確認 追従だけでは「他者が直した」事実を見落とす
私はこうしてたどり着いた基準を、いまも使い続けています。基準は単純です。その読み取りが「これから何をするか」を決めるなら、迷わず取り直す。 表示や集計のような、間違っても後で直せる読み取りなら追従で十分です。判断を駆動する読み取りだけ、鮮度に厳しくする。これで取り直しの回数を抑えつつ、誤判断の芽を摘めます。
/tmp の浅いクローンが所有者違いで消せない、といった付随トラブルにぶつかることもあります。その場合は固執せず、書き込める別のパスへ新規クローンしてしまうのが結局いちばん速いです。鮮度と所有権の問題を、取り直し一回でまとめて解消できます。
二重作業を防ぐのは「冪等性」
鮮度の検知は、二重作業を防ぐ仕組みの半分でしかありません。残り半分は、生成そのものを冪等にすることです。つまり「すでに片付いているなら、何もしない」を、生成の直前に実体で確かめます。
# 生成の前に、成果物が既に存在するかを実体で確認する
TARGET = "content/articles/ja/${ CAT }/${ SLUG }.mdx"
if [ -f " $TARGET " ]; then
echo "skip: $SLUG は既に存在します(二重生成を回避)"
exit 0
fi
# ここから先は「本当にまだ無い」ことが保証された状態
地味ですが、これがあると鮮度検知をすり抜けた取りこぼしも止まります。鮮度の検知は「古い土台で考え始めること」を防ぎ、冪等性のチェックは「同じ成果物を二度作ること」を防ぐ。役割が違うので、両方あって初めて安心できます。今回の二重修正も、この実体確認が生成の前にあれば、古い木を読んでいたとしても最後の一歩で止まっていました。
運用に組み込む — 判定関数とログ
最後に、ここまでを一つの判定にまとめます。無人で走る前提なので、判断の根拠は必ずログに残します。後から「なぜ取り直したのか」を人間が追えることが、無人運用の安心につながります。
# refresh_if_stale: 判断の前に呼ぶ。古ければ true を返す
refresh_if_stale () {
local work = " $1 " remote_url = " $2 "
cd " $work " || return 1
local local_sha remote_sha
local_sha = $( git rev-parse HEAD )
remote_sha = $( git ls-remote " $remote_url " -h refs/heads/main | cut -f1 )
if [ " $local_sha " = " $remote_sha " ]; then
echo "[fresh] $( TZ = Asia/Tokyo date +%H:%M) local==remote ${ local_sha : 0 : 7 }"
return 1
fi
echo "[stale] local=${ local_sha : 0 : 7 } remote=${ remote_sha : 0 : 7 } -> 取り直し"
return 0
}
# 呼び出し側
if refresh_if_stale " $WORK " " $REMOTE_URL " ; then
# 判断を駆動する読み取りの前にだけ取り直す
rm -rf " $WORK " && git clone --depth 1 " $REMOTE_URL " " $WORK "
fi
TZ=Asia/Tokyo を明示しているのは、ログの時刻が UTC に振れて前日の記録を上書きしてしまう事故を避けるためです。これも無人運用で一度痛い目を見て、以来かならず付けるようにしました。判定そのものは数行ですが、「鮮度を疑う場所を決めておく」という設計の意思が、二重作業や誤判断を未然に止めてくれます。
次の一歩としては、いま動かしている無人処理を一つ選び、「判断を駆動する読み取り」がどこかを書き出してみてください。そこにだけ refresh_if_stale を一段かませる。たいていは関数一つで、静かな誤判断の経路をまるごと塞げます。お読みいただきありがとうございました。