前の晩に仕込んだ処理が、翌朝には一行だけログを残して終わっていました。エラーではありません。「別のプロセスが実行中のため、今回は見送ります」という、私自身が書いた分岐です。
けれども、そのとき動いていたプロセスはひとつもありませんでした。
個人開発の作業を無人のスケジュール実行に任せるようになってから、同じ連携フォルダを複数の枠が触る場面が増えました。二重起動だけは避けたい。そう考えてロックを置いたのですが、その置き場所を「いちばん確実に共有されている場所」——連携フォルダそのもの——にしたことが原因でした。
排他は効いていました。効きすぎて、解除できなくなっていたのです。
排他制御そのものは、正しく動いていました
最初に疑ったのはロックの取得側です。競合していないのに競合したと判定しているのなら、取得の条件がおかしい。そう思って、まず排他が本当に働いているかを確かめました。
# 1本目が3秒間ロックを保持している間に、2本目を投げる
( flock -n 9 && sleep 3 ) 9> "$SHARED/slot.lock" &
sleep 0.5
( flock -n 9 && echo "2本目が取得(想定外)" \
|| echo "2本目はブロック(想定どおり)" ) 9> "$SHARED/slot.lock"
wait
結果は 2本目はブロック(想定どおり) でした。連携フォルダの上でも、flock の排他はきちんと働いています。ネットワーク越しのファイルシステムでロックが効かない、という古典的な話ではありませんでした。
問題は取得ではなく、その後にありました。
削除だけが、静かに拒まれていました
同じ場所で、ファイルの一生を分解して試しました。作成、追記、読み取り、改名、ディレクトリ作成、そして削除です。
T="$SHARED/probe_test"
echo x > "$T" # 作成
echo y >> "$T" # 追記
cat "$T" > /dev/null # 読み取り
mv "$T" "$T.renamed" # 改名
rm -f "$T.renamed" # 削除
mkdir "$SHARED/probe_dir" && rmdir "$SHARED/probe_dir"
私の環境で返ってきた結果です。
| 操作 | 連携フォルダ(クラウド同期) | サンドボックスのローカル領域 |
作成(>) | 成功 | 成功 |
追記(>>) | 成功 | 成功 |
| 読み取り | 成功 | 成功 |
改名(mv) | 成功 | 成功 |
ディレクトリ作成(mkdir) | 成功 | 成功 |
削除(rm) | Operation not permitted | 成功 |
ディレクトリ削除(rmdir) | Operation not permitted | 成功 |
書けるかどうかだけを確かめていたら、この場所は合格します。実際、私は合格させていました。作れて、追記できて、名前まで変えられるのですから、書き込み権限の観点では何の問題もありません。
拒まれるのは削除だけです。そして二重起動防止のロックは、削除できて初めて成り立つ仕組みでした。
三つのロック方式を、同じ場所で二度ずつ回す
ロックの実装方式は主に三つあります。同じ連携フォルダで、それぞれを続けて二回走らせました。二回目が動くかどうかが、無人運用では決定的です。
# ① mkdir ロック(存在ベース)
for run in 1 2; do
if mkdir "$SHARED/mkdir.lock" 2>/dev/null; then
echo "run$run: 取得"
rmdir "$SHARED/mkdir.lock" 2>/dev/null \
&& echo "run$run: 解放" || echo "run$run: 解放できず"
else
echo "run$run: 取得できず"
fi
done
| 方式 | 1回目の取得 | 排他 | 解放 | 2回目の実行 |
mkdir ロック | 成功 | 効く | 拒否 | 永久に取得できず |
set -o noclobber ロック | 成功 | 効く | 拒否 | 永久に取得できず |
flock(ファイル記述子) | 成功 | 効く | 不要(記述子を閉じれば解放) | 取得できる(ファイルだけが残る) |
はじめの二つは、ロックの正体が「そのファイルやディレクトリが存在すること」です。削除できない場所では、一度掛けたロックが二度と外れません。一回目の実行がロックを掛けた瞬間に、その枠は将来のすべての実行を締め出したことになります。
flock だけが違う理由は、排他の所在がファイルの存在ではなく、開いているファイル記述子にあるからです。プロセスが終われば記述子は閉じ、ロックは自動的に解けます。ファイルが消せなくても、次の実行はきちんと取得できました。残るのは中身ゼロのファイル一つだけです。
無人運用で選ぶべき方式が、ここではっきりしました。削除できない場所に置かざるを得ないなら、存在ベースのロックは使えません。
すでに存在ベースのロックで動いている処理を移すときは、次の順で進めると本番運用を止めずに済みました。
- 現在のロックの置き場所で削除が通るかを確かめる。通るのであれば、急いで直す必要はありません
flock 方式へ差し替え、置き場所だけをローカルの専有領域へ移す。取得の判定ロジックは触らない
- 旧方式の残骸が締め出しを起こしていないか、台帳の見送り回数で確かめる
順番を逆にすると、残骸を抱えたまま新方式へ移ることになり、切り分けが難しくなります。私はここでいちど回避策を挟もうとして、かえって確かめるべき状態を増やしてしまいました。存在ベースのロックを採用する場合は、削除が通る場所であることを先に確かめることを推奨します。
「取得できなかった」と「前が動いている」は別の事実です
もう一段やっかいなのは、失敗の見え方でした。
存在ベースのロックは、生きているロックと死んだロックを区別しません。mkdir が失敗したとき、返ってくる情報は「すでに存在する」だけです。それが三秒前に別プロセスが掛けたものなのか、三週間前の残骸なのかは分かりません。
そして多くの実装は、この状態を「前の実行がまだ動いているのだろう」と解釈して、正常終了します。終了コードは 0、ログには一行、通知は飛びません。私の朝がまさにそれでした。処理は止まっているのに、記録の上では何も壊れていないのです。
ロックファイルに PID を書けば区別できるのでは、と考えたのですが、この構成では役に立ちませんでした。無人実行は毎回別のサンドボックスで走るため、記録された PID を現在のプロセス一覧と突き合わせても意味がありません。存在しない番号が返るだけです。
見送りを正常終了として扱わないこと。ここが分かれ目でした。無音の見送りをどう記録に残すかは、Cowork スケジュールタスクが黙って止まる理由と、自動で立ち直る仕組みの作り方でも扱っています。
なぜ削除だけが拒まれるのか
この挙動を、環境の不具合として片付けたくなる瞬間がありました。けれども調べていくと、理にかなった設計だと分かります。
連携フォルダは、こちらが渡した実際の作業フォルダです。そこにあるのは、原稿であり、設定であり、記録です。自動で走る処理に対して、作成や追記は許しても、削除だけは人の判断を通す——そう区切っておけば、取り返しのつかない事故はまず起きません。改名が通るのも同じ線引きの内側です。名前が変わったファイルは探せますが、消えたファイルは探せません。
つまりこれは、防ぎたい事故の側から引かれた線です。恨む対象ではなく、前提として設計に織り込むべきものでした。
同じ前提は、連携フォルダに何を置くかという判断全体に効いてきます。私がフォルダを渡す前に中身を数えた記録はフォルダを AI に渡す前に鍵を数えたら、ファイル名で見つかるのは18件中5件でしたにまとめました。置いてよいものと、置いた瞬間に取り返せなくなるものは、別々に考える必要があります。
寿命で置き場所を分ける
原因が分かってからは、置き場所の判断基準を「共有されているか」から「寿命が短いか」に変えました。
| 置くもの | 置き場所 | 理由 |
| ロック・一時ファイル・作業用の複製 | ローカルの専有領域 | 実行のたびに作られ、実行が終われば消える必要がある |
| 実行台帳・ログ・成果物 | 連携フォルダ(追記のみ) | 後から読み返すために残す。消す必要がない |
| 設定・参照データ | 連携フォルダ(読み取り) | 人が更新し、処理は読むだけ |
台帳とログは、むしろ削除できない場所にあるほうが安心です。追記しかしないのですから、削除が拒まれても困りません。実際、私の環境では連携フォルダへの追記は問題なく通りました。
一方でロックは、消えることが仕様の一部です。消せない場所には置けません。
そのまま動く実装
ロックの置き場所を、実行前に判定してから決めるようにしました。存在するかでも、書けるかでもなく、後始末まで通るかを確かめます。
#!/usr/bin/env bash
# 無人実行の二重起動を防ぐロック。
# 「削除まで通る場所」以外にはロックを置かない。
set -uo pipefail
SLOT="${1:?slot name required}" # 例: nightly-report
SHARED_LEDGER="${SHARED_LEDGER:-}" # 追記のみ。連携フォルダで構わない
# ロック置き場の候補。専有かつローカルなものから順に見る
lock_root_candidates() {
printf '%s\n' \
"${XDG_RUNTIME_DIR:-}" \
"/run/user/$(id -u 2>/dev/null || echo 0)" \
"${TMPDIR:-/tmp}" \
"$HOME/.cache"
}
# 作成と削除が両方通ることを確かめる。
# 検査痕は固定名にして、実行ごとに増やさない
supports_full_lifecycle() {
local dir="$1" probe
if [ -z "$dir" ] || [ ! -d "$dir" ] || [ ! -w "$dir" ]; then return 1; fi
probe="$dir/.lifecycle_probe"
# 前回の検査痕が残っている = ここは後始末が通らない場所だと確定済み
if [ -e "$probe" ]; then return 1; fi
if ! mkdir "$probe" 2>/dev/null; then return 1; fi
if ! rmdir "$probe" 2>/dev/null; then return 1; fi
return 0
}
pick_lock_root() {
local d
while IFS= read -r d; do
if supports_full_lifecycle "$d"; then printf '%s\n' "$d"; return 0; fi
done < <(lock_root_candidates)
return 1
}
LOCK_ROOT="$(pick_lock_root)" || {
echo "FAILED: ロックを置ける場所がありません(削除まで通る領域が見つかりません)" >&2
exit 3
}
LOCK_FILE="$LOCK_ROOT/unattended-$SLOT.lock"
exec 9> "$LOCK_FILE" || exit 3
if ! flock -n 9; then
echo "SKIPPED: $SLOT は別プロセスが保持中です" >&2
[ -n "$SHARED_LEDGER" ] && printf '%s\t%s\t%s\t%s\n' \
"$(TZ=Asia/Tokyo date +%FT%T%z)" "$SLOT" "$$" "skipped" >> "$SHARED_LEDGER"
exit 2 # 0 で終わらせない
fi
trap 'flock -u 9 2>/dev/null; rm -f "$LOCK_FILE" 2>/dev/null' EXIT
[ -n "$SHARED_LEDGER" ] && printf '%s\t%s\t%s\t%s\n' \
"$(TZ=Asia/Tokyo date +%FT%T%z)" "$SLOT" "$$" "acquired" >> "$SHARED_LEDGER"
# ---- ここに本来の処理を書く ----
[ -n "$SHARED_LEDGER" ] && printf '%s\t%s\t%s\t%s\n' \
"$(TZ=Asia/Tokyo date +%FT%T%z)" "$SLOT" "$$" "released" >> "$SHARED_LEDGER"
exit 0
一本目が保持している状態で二本目を投げると、二本目は次のように終わります。
SKIPPED: nightly-report は別プロセスが保持中です
2本目 exit=2
台帳側には、こう積み上がります。
2026-08-29T12:14:57+0900 nightly-report 22 acquired
2026-08-29T12:14:57+0900 nightly-report 34 skipped
2026-08-29T12:15:00+0900 nightly-report 22 released
書き終えてから気づいたことがあります。この検査関数は、後始末が通らない場所では検査痕そのものを片付けられません。「後始末できないことを調べる処理」が後始末を必要とするという、少し可笑しな構造になっています。
だから検査痕は固定名にしました。判定に失敗した場所には検査痕が一つだけ残り、次回はその存在を見て、何も作らずに同じ結論を返します。実行するたびに痕跡が増えるのと、生涯で一つだけ残るのとでは、共有フォルダにとって意味がまったく違いました。
存在チェックと実際に試す検査の差については、存在チェックが通っても書けない — 無人実行の前提を能力プローブで測るでも別の角度から扱っています。
見送りを、記録の残る結果にする
終了コードを分けたのは、無音で止まる期間をなくすためでした。
| 終了コード | 意味 | 運用側の扱い |
| 0 | 処理を完了した | 成果物とログの両方を確認する |
| 2 | 生きたロックがあったので見送った | 記録に残す。連続したら異常として扱う |
| 3 | ロックを置ける場所がなかった | 即座に失敗として扱う。環境側の問題 |
台帳を一行ずつ数えれば、異常はすぐに見つかります。acquired に対応する released がない行が積み上がっていれば、掴んだまま落ちた実行があるということです。skipped だけが並んでいれば、掴んでいる相手がいないのに見送り続けている状態です。
awk -F'\t' '
$4=="acquired" { open[$3]++ }
$4=="released" { open[$3]-- }
$4=="skipped" { skip++ }
END {
for (p in open) if (open[p] > 0) print "未解放:", p
print "見送り回数:", skip+0
}
' "$SHARED_LEDGER"
私はこの出力を朝の確認に混ぜています。数字が二桁になっていたら、その枠は動いていないと判断して構いません。
何を自動化しないか
直したあとで、あえて実装しなかったものを書き残しておきます。
古いロックを自動で壊す仕組みは入れませんでした。flock を使う限り必要がないからです。記述子ベースのロックは、プロセスが消えれば解けます。壊す処理を持たせると、その処理が誤爆したときに二重起動を招きます。必要のない権限は持たせないほうが安全でした。
連携フォルダの掃除も自動化していません。削除に人の判断を通す設計を、こちらの都合で迂回させたくないからです。残った検査痕は生涯で一つ、中身は空です。それくらいは残しておいて、消すかどうかは自分で決めれば十分だと考えています。
他のセッションが置いたファイルにも触れないことにしました。同じ機械の上で別の無人実行が走っている可能性があり、その一時ファイルを掃除して困るのは向こうです。
次に確かめる一つのこと
もし無人で走る処理をお持ちなら、今日試せることが一つあります。ロックファイルが置かれている場所で、rm を一度だけ実行してみてください。
: > "$(dirname "$YOUR_LOCK_FILE")/.rm_check" && \
rm -f "$(dirname "$YOUR_LOCK_FILE")/.rm_check" && \
echo "削除まで通ります" || echo "この場所にロックは置けません"
通れば、今の構成のままで問題ありません。通らなければ、二度目の実行はもう始まっていない可能性があります。私はこれを確かめるのに、一晩と少しを使いました。同じ時間を使わずに済む方がいれば嬉しく思います。