Hermes Agent cronの二重実行を防ぐ|tick lockとflockを使い分ける

23 時の下書き job が走っている最中に、進みが気になって同じ処理を手で実行したくなります。2026 年 9 月 1 日の夜、私はその手を止めました。二重に走った下書き処理は、同じファイルへ書き込みを重ねるからです。
Hermes Agent の cron には.tick.lockという内蔵の lock があります。しかし、この lock が守る範囲を広く見積もると、手動実行や外部 wrapper との競合を素通しにします。守る対象が scheduler なのか、業務の副作用なのかで、置くべき lock は変わります。
前回は、正常時の最終応答を[SILENT]だけにし、異常があるときだけ通知を届けました。今回は、同じ副作用へ触れる実行経路を 1 本の業務 lock で直列化します。
前回から今回への差分
| 項目 | 前回完了時 | 今回完了後 |
|---|---|---|
| 通知 | 正常時は[SILENT]で静音にします | 変更しません |
| scheduler tickの重複 | 内蔵の.tick.lockに任せています | 役割を確認し、そのまま任せます |
| 同じ副作用への手動実行 | 直列化していません | 業務lockで即時skipします |
| 競合時の終了コード | 決めていません | 75を正常なskipとして扱います |
| lockの置き場所 | ありません | 同一ホストのローカルファイルシステムに置きます |
すでに flock の-Eと競合終了コードを使い分けている場合は、「NFS と CIFS の注意」まで飛ばせます。
なぜtick lockだけでは足りないのか
公式ドキュメントでは、gateway が 60 秒ごとに scheduler tick を実行し、期限の来た job を確認します。この tick が重ならないよう、profile-local の~/.hermes/cron/.tick.lockが排他します。守っているのは scheduler の内部動作です。
一方、私の夜の下書き処理には、cron job 以外の入り口があります。動作確認のための手動実行、同じファイルを触る別の job、シェルから直接叩く script です。tick lock はこれらの経路を知りません。
つまり、lock は 2 層です。scheduler の重複は Hermes Agent が内蔵 lock で防ぎます。業務の副作用を守るのは、すべての実行経路が通る共通の lock です。この境界を混ぜると、lock が存在しても二重実行の事故が起きます。
[gateway] 60秒ごとのscheduler tick
│
├─ ~/.hermes/cron/.tick.lock ← tickの重複を防ぐ(profile-local)
│
▼
[cron job] ────┐
[手動実行] ────┤ 同じ副作用(下書きファイルの更新)に触れる経路
[別のscript] ──┘
│
▼
flock -n -E 75 /run/user/$(id -u)/hibanas-night-draft.lock <command>
│
├─ lockを取れた → 業務処理を実行します
└─ 競合した ───→ exit 75 → 正常なskipとして記録します
用語を揃える
| 用語 | この手順での意味 |
|---|---|
| scheduler tick | gatewayが60秒ごとに期限のjobを確認する処理です |
| tick lock | ~/.hermes/cron/.tick.lockです。profile内でtickの重複を防ぎます |
| 業務lock | 副作用の単位で置く共通lockです。ここでは/run/user配下のfileです |
| 排他ロック | 同時に1つの保持者だけを許すロックです。flockのデフォルトです |
-n | lockを待たず、取れなければ即座に競合扱いで終了する指定です |
| conflict exit code | 競合時の終了コードです。-Eで指定し、デフォルトは1です |
| EX_TEMPFAIL | sysexits.hが75に割り当てた「一時的な失敗」の慣例です |
| 副作用 | 下書きファイルの更新など、二重実行で壊れる操作です |
実測で競合時の終了コードを確かめる
2026 年 9 月 1 日、Linux ホストで確認しました。まず flock の version を読みます。
flock --version
実測の出力です。
flock from util-linux 2.41
次に、競合したときのデフォルトの終了コードを確かめます。端末を 2 つ開き、1 つ目で排他ロックを 60 秒保持します。
flock /run/user/$(id -u)/overlap-demo.lock sleep 60
保持中に、2 つ目の端末から-n付きで同じ lockfile を狙います。
flock -n /run/user/$(id -u)/overlap-demo.lock true
echo "second_runner_exit=$?"
実測結果です。
second_runner_exit=1
競合のデフォルトは exit 1 です。man page のとおり、この値は-Eで変更できます。exit 1 のままでは、業務コマンドの一般的な失敗と区別が付きません。監視側から見ると「skip したのか壊れたのか」が同じ数字になります。だから競合専用のコードを割り当てます。
競合を正常なskipへ変換するwrapper
中心になる 1 行はこれです。
flock -n -E 75 /run/user/$(id -u)/hibanas-night-draft.lock <command>
-nは待たない指定です。夜の下書き処理は翌日また走るので、待つ価値がありません。-E 75は競合時の終了コードを 75 へ変えます。75 は sysexits.h の EX_TEMPFAIL で、「今回は駄目だが後で成立する」という慣例の値です。lockfile は/run/user/$(id -u)配下に置きます。ユーザー専用の tmpfs なので、権限調整なしで書けます。
この 1 行を script に包み、75 だけを正常な skip として吸収します。
#!/usr/bin/env bash
set -u
lock="/run/user/$(id -u)/hibanas-night-draft.lock"
log="$HOME/.local/state/hibanas/night-draft-skip.log"
flock -n -E 75 "$lock" "$@"
status=$?
if [ "$status" -eq 75 ]; then
mkdir -p "$(dirname "$log")"
printf '%s skip\n' "$(date --iso-8601=seconds)" >> "$log"
exit 0
fi
exit "$status"
競合したら exit 0 で戻り、記録は local の log file へ残します。--no-agentの script job では、成功時の stdout が空なら通知しません。通常の agent 付き job では、この後に LLM が動くため、正常な skip にも[SILENT]を返す指示が必要です。終了コードだけで通知の有無は決まりません。競合以外の終了コードはそのまま返します。
cron job が実行するコマンドも、手で叩くコマンドも、この wrapper 経由に統一します。排他のために Hermes Agent 側の設定を追加する必要はありません。実行するコマンド列の先頭に wrapper を足します。通知の抑制は、上記の実行モードに応じて別に設定します。経路が 1 つでも wrapper を通らないと、その経路だけ排他から漏れます。
1 つ注意があります。包んだ業務コマンド自身が 75 を返す設計だと、競合と区別できません。その場合は-Eの値を、業務コマンドが使わないコードへずらします。
NFSとCIFSの注意
flock コマンドの排他は flock(2) に基づきます。man page は、NFS など一部のファイルシステムで実装に制約があり得ることに触れています。CIFS も同様に、mount の条件次第で期待した排他になりません。
だから lockfile は、実行するホスト自身のローカルファイルシステムに置きます。/run/user/$(id -u)は tmpfs なので、この条件を満たします。再起動で消えますが、flock は実行のたびに file を開いて lock するので、file が残っている必要はありません。
逆に言えば、この方式はホストをまたぎません。複数ホストが同じ副作用に触れる構成なら、flock ではなく別の排他手段が必要です。今回の下書き処理は 1 ホスト完結なので、flock で足ります。
よくあるエラー
| 症状 | 原因 | 確認と修正 |
|---|---|---|
| 手動実行とcronが二重に走りました | tick lockがschedulerだけを守っています | すべての実行経路を業務lockのwrapper経由へ統一します |
| skipのたびに通知が出ます | exit 75の伝播か、agentの最終応答が原因です | wrapperで75を正常終了へ変換し、agent付きなら正常時の[SILENT]指示も確認します |
| 本物の失敗までskip扱いになりました | 業務コマンド自身が75を返しています | -Eの値を業務コマンドが使わないコードへ変更します |
| 共有ストレージ上でlockが効きません | NFSやCIFSにlockfileを置いています | 同一ホストのローカルファイルシステムへ移します |
/run/user配下に書けません | セッションが閉じてtmpfsが消えています | loginctl enable-lingerで維持するか、ローカルの別の場所へ置きます |
| 長時間jobの間ずっとskipします | -nが即時にあきらめる指定です | 待ってよい処理なら-w 秒数の待機へ切り替えます |
| skipしたことに気付けません | 記録をどこにも残していません | wrapperのlog fileを監査に含めます |
私なら副作用ごとにlockを1本にする
lock の数は、守りたい副作用の数に合わせます。job の数には合わせません。下書きファイルを触る経路が 3 つあるなら、3 つとも同じ lockfile を通します。lockfile の名前も、job 名ではなく副作用の名前にします。
内蔵の tick lock は便利ですが、あれは Hermes Agent が自分の scheduler を守る仕組みです。私の業務データを守る責任までは負っていません。境界の内側は任せ、外側は自分で流します。
競合を異常とみなしません。排他が働いた証拠です。exit 75 を正常な skip として扱い、実行モードに合う静音設定を組み合わせます。log には「skip した夜」が残ります。二重実行の事故を、記録付きの空振りへ置き換えます。
一次情報
| 出典 | 確認した内容 |
|---|---|
| Hermes Agent Scheduled Tasks | gatewayの60秒tick、profile-localの.tick.lockによるscheduler tickの排他 |
| flock(1) - Linux manual page | -nと-Eの動作、競合時のデフォルト終了コード、ファイルシステムによる制約 |
| sysexits.h(3head) - Linux manual page | EX_TEMPFAIL=75の定義 |
