朝、スケジュール実行のログを開くと、成果物は確かに一本増えていました。けれど中身を読んで、背筋が少し冷えました。
それらしい体裁は整っているのに、肝心の固有情報が抜け落ちている。原因をたどると、参照していたデータファイルが、その時刻にはまだ空だったのです。前段の更新処理が数分遅れただけで、後段は「空のファイルを正常に読み込んだ」と判断し、薄い成果物を黙って世に出していました。
無人で処理を回すとき、いちばん怖いのはエラーで止まることではありません。止まらずに、間違ったまま完走してしまうことです。エラーは気づけます。けれど「静かな劣化」は、誰かが成果物を読み返すまで見つかりません。
私は個人開発の傍ら、Coworkのスケジュール機能で複数サイトの自動更新を回しています。その運用のなかで何度かこの種の事故に遭い、対症療法では再発が止まらないと痛感しました。要るのは、入力そのものを「信頼できる前提」として毎回検証する仕組み。本稿で扱うのは、それを「入力フレッシュネス契約」という形に落とし込む方法です。
無音で劣化するのは、たいてい入力側の問題です
成果物が静かに劣化するとき、生成ロジック自体が壊れていることは意外と少ないものです。多くは、生成器に渡る前の「入力」が想定と違っているのに、それを誰も検めていない。私が繰り返し見てきたのは、次の三つの形でした。
一つめは、空のファイルです。cat でパスを読み違えても、存在しないパスを読んでも、シェルは空文字列を返して何事もなかったかのように進みます。生成器は「参照データはありませんでした」と素直に受け取り、汎用的で当たり障りのない成果物を作ります。
二つめは、古いファイルです。前段の更新が失敗、または遅延しても、前回のファイルは残っています。後段はそれを「今日のデータ」として読み、昨日の文脈で今日の成果物を作ってしまう。ファイルは存在し、中身もある。けれど鮮度がない。
三つめは、前回の残骸です。固定名の一時ファイルに書き込む設計だと、今回の書き込みが失敗したときに前回の中身がそのまま残り、無音で混入します。これは空や古いより厄介で、もっともらしく見えるぶん発見が遅れます。
共通しているのは、どれも「処理は成功している」ように見えることです。だからこそ、入力を疑う関門を明示的に置かない限り、劣化は静かに通り抜けます。
入力を「契約」として扱う — 鮮度・非空・由来
そこで私は、上流の入力を暗黙の前提ではなく、明示的な契約として扱うことにしました。契約と呼ぶのは、満たされなければ後段に進ませない、という強制力を持たせたいからです。検証する条件は三つに絞っています。
鮮度(freshness)は、ファイルが許容範囲内に更新されているか。毎日再生成される参照データなら、たとえば24時間以内であること。閾値を超えたら「古い」と判定します。
非空(non-empty)は、最低限の中身があるか。単にゼロバイトでないだけでなく、その入力に意味のある下限バイト数を設けます。見出しだけの数十バイトを「内容あり」と誤認しないためです。
由来(provenance)は、その入力が正しい出所のものか。クローンしたリポジトリなら、ローカルのHEADがリモートの最新と一致しているか。一時ファイルなら、今回の実行で書かれたものか。古い場所を「最新」と取り違えないための条件です。
この三つを満たした入力だけを「信頼できる」とみなし、一つでも欠けたら実行を止める。判断を生成器に委ねず、関門で機械的に弾くのが要点です。
フレッシュネス・ゲートを書く
契約は、コードにして初めて効きます。私が使っているのは、入力の一覧を書いたマニフェストを読み、各ファイルの鮮度と非空を検証する小さなbashゲートです。依存を増やしたくないので、外部ツールには頼らず標準コマンドだけで組んでいます。
まずマニフェストは、検証したい入力をタブ区切りで並べただけのものです。
# path<TAB>max_age_min<TAB>min_bytes
_documents/_reference_data/claudelab/reference_data.md 1440 500
_documents/_reference_data/claudelab/reference_keywords.txt 1440 200
_documents/_reference_data/_shared/tech_trends_2026.md 2880 400
各行が一つの契約です。1440分(24時間)以内に更新され、500バイト以上あること。これを満たさなければ、その入力は使わせません。
ゲート本体は、このマニフェストを上から検証していきます。
#!/usr/bin/env bash
# input_freshness_gate.sh — 上流入力の鮮度・非空を検証して契約違反なら止める
# 使い方: input_freshness_gate.sh <manifest.tsv>
set -euo pipefail
manifest = " ${1 :? manifest path required } "
now = $( date +%s )
fail = 0
while IFS = $' \t ' read -r path max_age_min min_bytes ; do
[ -z "${ path :- }" ] && continue
case " $path " in \# * ) continue ;; esac # コメント行は飛ばす
if [ ! -f " $path " ]; then
echo "❌ missing: $path " ; fail = 1 ; continue # 存在しない=空読みの温床
fi
bytes = $( wc -c < " $path " | tr -d ' ' )
if [ " $bytes " -lt " $min_bytes " ]; then
echo "❌ too small: $path (${ bytes }B < ${ min_bytes }B)" ; fail = 1 ; continue
fi
mtime = $( stat -c %Y " $path " 2> /dev/null || date -r " $path " +%s )
age_min = $(( ( now - mtime ) / 60 ))
if [ " $age_min " -gt " $max_age_min " ]; then
echo "❌ stale: $path (age ${ age_min }min > ${ max_age_min }min)" ; fail = 1 ; continue
fi
echo "✅ ok: $path (${ bytes }B / ${ age_min }min)"
done < " $manifest "
[ " $fail " -eq 0 ] || { echo "🛑 freshness gate failed — abort" ; exit 1 ; }
echo "✅ all inputs fresh"
六十行に満たない小さなコードですが、効果は大きいものでした。set -euo pipefail で想定外の失敗を握りつぶさないこと、exit 1 で後段に進ませないことが肝です。生成処理を呼ぶ前にこのゲートを通し、非ゼロ終了なら実行全体を中断します。
ここで一点、見落としやすい落とし穴があります。mtime の取得方法はOSで違います。Linuxのコンテナでは stat -c %Y、macOSでは date -r が要ります。両方を || でつないでおくと、どちらの環境でも黙って間違えずに済みます。私は当初Linux前提で書いてしまい、手元のmacで stat が別の値を返して鮮度判定が狂った経験があります。
由来は、HEADの一致で確かめる
鮮度と非空はファイル単体で測れますが、由来はもう一段踏み込みが要ります。とくにクローンしたリポジトリを入力にする場合、「そのクローンが本当に最新か」を確かめないと、古い状態を最新と取り違えます。
私はこれを、ローカルのHEADとリモートの先頭コミットを突き合わせて判定しています。
git fetch --depth 1 origin main -q
local_sha = $( git rev-parse HEAD )
remote_sha = $( git rev-parse origin/main )
if [ " $local_sha " != " $remote_sha " ]; then
echo "❌ stale clone: local ${ local_sha : 0 : 7 } != remote ${ remote_sha : 0 : 7 }"
exit 1
fi
由来の検証は、成果物を書き出したあとにも効きます。生成器が成果物を作ったら、その成果物が期待するトークン(固有名詞や対象スラッグなど)を実際に含むかをassertし、欠けていれば失敗扱いにする。「書けたはず」を「書けたと確認した」に変えるだけで、無音の取りこぼしはかなり減ります。
失敗したら止める、止めたら気づける
契約は、破れたときに止まるだけでは半分です。止まったことに気づける導線がなければ、無人運用では「静かに何も出ない」という別の劣化に化けます。
私は二段構えにしています。まずゲート違反時には、_FAILED を含むログファイルを欠落内容つきで残す。次に、当日の成果物が一件もないことを翌朝のチェックで検知する。前者は「なぜ止まったか」、後者は「止まったこと自体」を拾うためのもので、役割が違います。
ここで一つ強く推奨します。フォールバックの設計です。入力が古いからといって即座にゼロ件で終わると、Stripeの課金導線やAdMobにつながる流入まで止めてしまいます。私の場合は、新規生成が無理なときは既存成果物の改善に切り替える、という退避先を必ず用意しています。止めるべきは「劣化した新規生成」であって、「その日の活動すべて」ではない、という線引きです。
コストの心配は、実測するとほぼ杞憂でした。全ゲートを足しても一回の実行で数秒、リポジトリ取得まで含めても全体の5%未満です。この数秒で、読み返したときに冷や汗をかく成果物を一本止められるなら、私は迷わず入れます。
私が踏んだ二つの落とし穴
設計の話だけでは伝わりにくいので、私自身が実際に踏んだ二つを共有します。どちらも「入力の由来を疑っていなかった」ことが根にありました。
一つめは、UTC日付による上書きです。スケジュール実行のログを日付つきファイル名で残していたのですが、コンテナの date は既定でUTCを返します。日本時間の早朝に走るタスクでは、UTCではまだ前日。結果として、前日のログを「今日のログ」と思い込んで上書きし、記録が消えました。時刻もまた一つの入力であり、由来(どのタイムゾーンの今日か)を固定しないと静かに壊れます。対処は単純で、TZ=Asia/Tokyo date のように明示するだけです。
二つめは、nobody所有のstaleクローンです。高速化のために永続化していた作業用クローンが、別プロセスの所有のまま書き込み不可になっており、しかも数日前の状態で止まっていました。それに気づかず読み続ければ、削除済みの古い情報を「現役」と誤認して、一度直したはずの内容をまた直しかけます。実はこの落とし穴は、本稿を書くために環境を準備した今日この時も再発しました。だからこそ、クローン自体を入力契約の対象に含め、書き込み可否とHEAD一致を毎回確かめてから使う、という習慣に変えています。
二つに共通する教訓は、ファイルやリポジトリが「そこにある」ことと「正しい今のものである」ことは別だ、という当たり前の事実です。無人運用では、その当たり前を毎回機械に確かめさせる必要があります。
最初に守る一線から始める
すべてを一度に整える必要はありません。まず守るべき一線は、生成処理を呼ぶ直前に「入力が空でないこと」だけを確かめる、たった数行のチェックです。これだけでも、無音劣化のかなりの部分は防げます。
そこから、鮮度の閾値、由来のHEAD一致、出力後のassertへと、痛い目を見た順に契約を足していけば十分です。私自身、最初から完成形だったわけではなく、一つ事故を起こすたびに関門を一つ増やしてきました。
無人で回すパイプラインに信頼を置けるかどうかは、結局のところ「入力をどれだけ疑えているか」に尽きると感じています。次にスケジュールタスクを書くとき、生成ロジックより先に、その入力が本当に新しく、空でなく、正しい出所のものかを確かめる一行を足してみてください。冷や汗をかく朝が、一つ減るはずです。