朝いちばんにログを開いたら、処理が1行目で止まっていました。
個人開発の作業を夜のうちに片付けるための処理で、前の晩まで何ヶ月も同じ手順で走っていたものです。コードは一文字も変えていません。変わったのは、実行環境の側でした。作業用に使っていたディレクトリの所有者が、いつの間にか別のユーザーになっていて、そこへ書き込めなくなっていた。それだけのことです。
ただ、私が引っかかったのはその先でした。手順の冒頭には、ちゃんと確認処理が入っていたのです。ディレクトリがあるかを調べ、無ければ作る。あるならそのまま使う。何年も疑わずに書いてきた形です。
その確認は、その朝も正常に通過していました。存在していたからです。
書けないまま。
存在していることは、使えることを意味しない
まず、手元で同じ状況を作って挙動を確かめました。所有者だけが読み込みと実行を持ち、書き込みを落としたディレクトリを用意します。
mkdir locked && chmod 500 locked
mkdir -p locked ; echo "mkdir -p exit= $? "
echo "-d test: $([ -d locked ] && echo true || echo false )"
echo "-w test: $([ -w locked ] && echo true || echo false )"
touch locked/x 2> /dev/null ; echo "touch exit= $? "
出力はこうなります。
mkdir -p exit=0
-d test: true
-w test: false
touch exit=1
mkdir -p が 0 を返している点が、私にとっては意外でした。作れなかったわけではなく、すでにあるから何もしなかった、という意味での 0 です。POSIX の定義としては正しい。けれど、mkdir -p "$DIR" && cd "$DIR" と書いた側は、この 0 を「これで使える状態になった」と読んでしまいます。
[ -d ] も同じです。存在を尋ねる述語に、権限を尋ねているつもりで頼っている。尋ねていないことに答えは返ってきません。
この日壊れたのは、まさにその隙間でした。
存在チェックと能力プローブを並べて測る
そこで、確認の方法を二種類に分けて比べることにしました。
一方は、これまで書いてきた存在チェック です。os.path.isdir、os.path.exists、.git ディレクトリの有無。もう一方は能力プローブ 、つまり「これから実際に行う操作を、小さく一度やってみる」方式です。書くなら書いてみる。消すなら消してみる。git を使うなら git rev-parse を通してみる。
環境側に、壊れ方の異なる5つの状態を作りました。実在するけれど書けないディレクトリ、ディレクトリのつもりがファイルだったパス、リンク切れのシンボリックリンク、.git はあるがリポジトリとして成立していないディレクトリ、そして読み取り専用でマウントされた実在の領域です。
結果です。
ケース 存在チェック 能力プローブ
存在するが書き込めないディレクトリ OK NG
ディレクトリのはずがファイル NG NG
リンク切れのシンボリックリンク NG NG
.git はあるがリポジトリではないOK NG
読み取り専用マウント OK NG
5件のうち3件、割合にして60%が、存在チェックだけを通過しました。しかも通過した3件は、いずれも「パスとしては完全に正しく、操作だけができない」という種類の壊れ方です。パスの綴りを疑っても永遠に見つかりません。
進んでしまった場合に返るメッセージも記録しました。
ケース 実際の失敗
書き込めないディレクトリ touch: cannot touch ...: Permission denied
ディレクトリのはずがファイル touch: cannot touch ...: Not a directory
.git はあるがリポジトリではないfatal: not a git repository(終了コード 128)
読み取り専用マウント touch: cannot touch ...: Read-only file system
無人実行の落とし穴は、これらのエラーが処理のずっと後ろで出ることです。前段の重い処理を全部終えてから、最後の書き出しで初めて落ちる。同じリポジトリの記事ファイル1,594件(約1,929万文字)を読んで集計する前段を実測すると 0.093秒でした。対して、5件の能力プローブは合計 2.57ミリ秒です。前段が数分かかる構成なら、その数分がまるごと捨てられることになります。
割合で見ると、前段の集計に対して能力プローブは約2.8%です。3回連続で計測したところ、「前段を全部やってから落ちる」経路と「先に測って落ちる」経路の所要時間の比は 6,470倍・8,315倍・6,892倍でした。この差が生まれるのは、実際に行う操作をそのまま、ただし極小の規模で一度だけ試す からです。権限の理屈を推論しないので、推論が外れる余地がありません。
書けるのに消せない場所がある
ここまでは、まだ想定の範囲でした。想定を外れたのは次です。
作業成果を置くために使っているマウント上のディレクトリを調べたときのことです。パーミッションは drwx------、所有者は自分。os.access(d, os.W_OK) は True を返します。
操作ごとに分けて測りました。
操作 結果
os.access(W_OK)True
作成(open(path, "w")) OK
追記(open(path, "a")) OK
改名(os.rename) OK
削除(os.remove) FAIL: Operation not permitted
作成でき、追記でき、改名までできるのに、削除だけが EPERM で拒まれます。
私はこの結果を見て、少し手が止まりました。書けるかどうかを一度確かめれば十分だと思っていたのに、書けることと消せることが別々に制御される場所が実在していたわけです。
実務上の影響は小さくありません。一時ファイルを作って、処理が終わったら片付ける。よくある形です。この場所では、片付けだけが必ず失敗します。しかも失敗するのは処理の最後、成果物の生成が済んだ後なので、「成功したのに終了コードが 1」という読みにくい状態になります。再実行すれば、前回の残骸が残ったまま次が始まります。
固定名の一時ファイルを使っていたら、その残骸を掴んでいたはずです。私自身、以前に固定名の一時ファイルで前回の残骸を混入させたことがあり、それ以来プローブ用のファイル名には毎回ランダムな接尾辞を付けるようにしています。
ここから導いた原則はひとつです。プローブは「書けるか」ではなく「自分がこれから行う一連の操作が通るか」を測る。
プリフライトを実装する
以上を踏まえて、実行直前に走らせるモジュールを書きました。宣言的に検査項目を並べ、それぞれについて実操作を小さく試し、失敗したものには対処方針を添えて返します。
#!/usr/bin/env python3
"""Preflight capability probes for unattended runs."""
from __future__ import annotations
import os
import subprocess
import time
import uuid
from dataclasses import dataclass, field
from typing import Callable
CREATE , REPLACE , DELETE , GIT_REPO , SECRET = "create" , "replace" , "delete" , "git" , "secret"
@dataclass
class Check :
name: str
kind: str
target: str
remedy: str # 失敗したときに人が取る行動
ok: bool = False
detail: str = ""
ms: float = field( default = 0.0 )
def _probe_path (kind: str , path: str ) -> tuple[ bool , str ]:
# 実行ごとに一意な名前にする。固定名にすると、
# 「削除できなかった前回のプローブ残骸」を掴むことがある。
probe = os.path.join(path, f ".preflight- { uuid.uuid4().hex[: 8 ] } " )
try :
with open (probe, "w" ) as fp:
fp.write( "probe" )
except OSError as exc:
return False , f "create: { exc.strerror } "
try :
if kind in ( REPLACE , DELETE ):
os.replace(probe, probe + ".tmp" )
probe = probe + ".tmp"
if kind == DELETE :
os.remove(probe)
return True , "create/replace/delete all permitted"
except OSError as exc:
try : # 消せる残骸は残さない
os.remove(probe)
except OSError :
pass
return False , f " { kind } : { exc.strerror } "
try :
os.remove(probe)
except OSError as exc:
# 削除を要求していない検査なら、削除不可は失格にしない
return kind != DELETE , f "created, but unlink failed: { exc.strerror } "
return True , "ok"
def _probe_git (path: str ) -> tuple[ bool , str ]:
try :
res = subprocess.run(
[ "git" , "-C" , path, "rev-parse" , "--git-dir" ],
capture_output = True , text = True , timeout = 15 ,
)
except ( OSError , subprocess.TimeoutExpired) as exc:
return False , f "git unavailable: { exc } "
if res.returncode != 0 :
return False , res.stderr.strip().splitlines()[ 0 ] if res.stderr else "not a repository"
return True , res.stdout.strip()
def _probe_secret (spec: str ) -> tuple[ bool , str ]:
# spec = "<file>::<label>"。本番と同じ読み方で取り出し、形だけ検査する。
# ここで空文字を通すと、ずっと後ろで認証エラーとして現れる。
path, _, label = spec.partition( "::" )
try :
with open (path, encoding = "utf-8" ) as fp:
lines = [ln.rstrip( " \n " ) for ln in fp]
except OSError as exc:
return False , f " { exc.strerror } : { path } "
for i, line in enumerate (lines):
if line.startswith(label) and i + 1 < len (lines):
value = lines[i + 1 ].strip()
if len (value) < 20 :
return False , f "value for { label !r } is { len (value) } chars — too short"
return True , f " { label } : { len (value) } chars"
return False , f "label { label !r } not found in { os.path.basename(path) } "
PROBES : dict[ str , Callable[[ str ], tuple[ bool , str ]]] = {
CREATE : lambda p: _probe_path( CREATE , p),
REPLACE : lambda p: _probe_path( REPLACE , p),
DELETE : lambda p: _probe_path( DELETE , p),
GIT_REPO : _probe_git,
SECRET : _probe_secret,
}
def run (checks: list[Check]) -> list[Check]:
for check in checks:
start = time.perf_counter()
try :
check.ok, check.detail = PROBES [check.kind](check.target)
except KeyError :
check.ok, check.detail = False , f "unknown probe kind: { check.kind } "
check.ms = (time.perf_counter() - start) * 1000
return checks
def report (checks: list[Check]) -> int :
failed = [c for c in checks if not c.ok]
total = sum (c.ms for c in checks)
for c in checks:
mark = "PASS" if c.ok else "FAIL"
print ( f "[ { mark } ] { c.name :<26 } { c.ms :6.2f } ms { c.detail } " )
print ( f " \n{ len (checks) - len (failed) } / { len (checks) } passed in { total :.2f } ms" )
for c in failed:
print ( f " -> { c.name } : { c.remedy } " )
return 1 if failed else 0
呼び出し側は、その日の実行が触る場所だけを列挙します。
checks = [
Check( "workspace/clone-root" , DELETE , f " { HOME } /repos" , "別の書き込み可能な場所へ退避する" ),
Check( "artifact-dir" , DELETE , outputs_dir, "削除できない場所は再実行前提の置き場に使わない" ),
Check( "repo/claudelab.net" , GIT_REPO , f " { HOME } /repos/claudelab.net" , "clone をやり直す" ),
Check( "credential" , SECRET , f " { tokens } ::Claude Lab" , "ラベル名と値を確認する" ),
]
sys.exit(report(run(checks)))
実際の出力です。
[PASS] workspace/clone-root 0.14ms create/replace/delete all permitted
[FAIL] artifact-dir 3.50ms delete: Operation not permitted
[PASS] repo/claudelab.net 2.15ms .git
[FAIL] locked-dir 0.04ms create: Permission denied
[PASS] credential 1.71ms Claude Lab: 40 chars
[FAIL] credential(typo) 1.14ms label 'ClaudeLab' not found in github_tokens.txt
3/6 passed in 8.68ms
-> artifact-dir: 削除できない場所は再実行前提の置き場に使わない
-> locked-dir: 所有者を確認して別パスへ
-> credential(typo): ラベル名と値を確認する
6項目で 8.68ミリ秒。前段の集計処理だけで 93ミリ秒かかることを思えば、その9.3%にあたる負荷です。
remedy を必須フィールドにしたのには理由があります。無人実行のログを読むのは、たいてい数時間後の自分です。Permission denied とだけ書かれたログから当時の判断を復元するのは骨が折れます。検査を書いた瞬間が、対処を一番よく分かっている瞬間なので、そこで一緒に書き残しておく。
プローブが本番と違う読み方をすると、嘘をつく
この実装、最初の版は正しい資格情報を不合格にしました。
上の _probe_secret は、当初 if line.strip() == label と書いていました。ラベル行が Claude Lab である前提です。ところが実ファイルのラベル行は Claude Lab (claudelab.net) で、末尾にドメインが付いていました。本番側は grep -A1 "^Claude Lab" と前方一致で読んでいたので通っていたのに、プローブだけが完全一致で読んで落としたわけです。
[FAIL] credential 1.65ms label 'Claude Lab' not found in github_tokens.txt
これは、私にとって一番こたえた失敗でした。壊れた前提を捕まえるために書いた仕組みが、自分の側の前提で嘘をついたからです。
しかも嘘の向きが二種類あります。プローブが本番より厳しい と、正常な環境を止めてしまう。緩いと、壊れた環境を通してしまう。前者は気づけますが、後者は気づけません。プリフライトを信用して本番の確認を薄くしていれば、緩い側の嘘は元より危険です。
対処は単純で、プローブと本番で読み取り処理を共有する ことを強くお勧めします。同じ関数を呼び、同じ正規表現を使い、同じ既定値を使う。別々に書いた瞬間、二つの実装が食い違う可能性が生まれます。
言い換えると、プリフライトは本番処理の縮小版 であるべきで、本番処理の要約 であってはいけない、ということになります。私はこの区別を、この一件でようやく体で覚えました。
シェル側では、代入が失敗を飲み込む
Python 側を固めても、起点がシェルだと別の穴が残ります。資格情報を取り出す、あの一行です。
GITHUB_TOKEN = $( grep -A1 "^Claude Lab" tokens.txt | tail -1 | tr -d '[:space:]' )
ラベルが見つからなくても、このコマンドは成功します。grep は 1 を返しますが、その後ろの tail が 0 を返し、パイプライン全体の終了コードは最後のコマンドのものになるからです。結果として GITHUB_TOKEN は空文字のまま次へ進みます。
そして空のトークンで clone すると、こう出ます。
fatal: could not read Username for 'https://github.com': terminal prompts disabled
トークンが空だとは、どこにも書かれていません。無人実行では対話プロンプトが出せないので、認証情報が無いという事実が「プロンプトを出せない」という表示に化けます。原因からもっとも遠い場所に、もっとも目立つメッセージが出る。
set -e を付ければ止まると思っていたのですが、実際に測ると挙動は分かれました(bash 5.1.16 で確認)。
書き方 set -e で止まるか
V=$(false)止まる
export V=$(false)止まらない
local V=$(false)(関数内)止まらない
V=$(false | cat)止まらない
V=$(false | cat) + set -o pipefail止まる
export と local が止まらないのは、その行の終了コードが export コマンド自身のもの(常に 0)になるためです。素の代入なら伝わる失敗が、export を一語足しただけで消えます。この二つを見分けられる人は多くないと思いますし、私も測るまで確信が持てませんでした。
実務上の結論はこうなります。
set -euo pipefail を先頭に置く。pipefail が無ければ、パイプを含む代入の失敗はすべて素通りします。
export VAR=$(...) と local VAR=$(...) は使わない。代入と export を二行に分ける。
値を得た直後に、値そのものを検査する。存在ではなく長さと形を見ます。
set -euo pipefail
token = $( grep -A1 "^Claude Lab" " $TOKENS " | tail -1 | tr -d '[:space:]' ) || true
if [ "${ # token }" -lt 20 ]; then
echo "preflight: credential for 'Claude Lab' is empty or too short (${ # token } chars)" >&2
exit 78 # EX_CONFIG — 設定側の問題であることを終了コードで示す
fi
export GITHUB_TOKEN = " $token "
終了コードを分けているのは、再試行の判断のためです。設定の不備は何度やり直しても直りません。ネットワークの瞬断とは別の扱いにしたい。
どこに置き、何を測らないか
置き場所は、実行直前です。前の晩に流した検査結果は、その晩の環境についての情報でしかありません。無人実行の環境が使い捨てのサンドボックスなら、なおさら毎回測り直す必要があります。
測る対象は、壊れたときに黙って進んでしまうもの に絞っています。逆に、次のものはプリフライトに入れていません。
測らないもの 理由
ネットワーク到達性 瞬断と恒久的な不通を区別できない。再試行とタイムアウトで扱う
ディスクの空き容量 閾値の根拠が持てない。実際に書けなくなった時点の ENOSPC で判断する
外部 API の応答 プローブ自体が課金と副作用を伴う
空き容量については補足させてください。df /tmp を見て安心していた時期があったのですが、実際に書き込む先が別のファイルシステムなら、その数値は何も保証しません。手元の環境でも /tmp と作業ディレクトリは別デバイスでした。測るなら、書き込む当のパスに対して測る。これも「存在チェックと能力プローブ」と同じ構図です。
Claude Code をヘッドレスで走らせる構成では、この種の前提はさらに動きやすくなります。バージョンが上がって Bash 実行やサブエージェント生成の制御が締められれば、昨日まで通っていた操作が今日は通らないということが起こり得ます。仕様の変化を追い続けるより、自分の処理が依存している能力を毎回その場で測る ほうが、結果として手間が少ないと私は感じています。
前提が変わったこと自体は、防げません。防げるのは、前提が変わったまま20分走り続けることのほうです。
まとめ
存在チェックは「そこにあるか」しか答えません。無人実行が静かに壊れるのは、たいてい「あるけれど使えない」という状態です。手元の5ケースでは、そのうち3件が存在チェックを素通りしました。
次の一手として提案したいのは、いま自動化している処理から書き込み先をひとつ選び、実行直前に小さなファイルを作って消す3行を足す ことです。それだけで、この記事で扱った失敗のうち二種類は当日中に見つかります。全項目を宣言的に整えるのは、その後で構いません。
私自身、この仕組みを入れてから、朝いちばんにログを読む時間が短くなりました。落ちるときは0.1秒で落ち、理由と対処が一行で書いてある。それだけのことが、思っていたよりずっと効きます。
こうした地味な検査の話にお付き合いいただき、嬉しく思います。