定期タスクの二重実行を止めるガードを8ケースで検証 ― state・lock・UTF-8

定期タスクが「もう実行済み」なのか、「途中で止まり、外部処理だけ済んだかもしれない」のかは同じではありません。前者は安全にskipできますが、後者を自動で再実行すると二重書き込みになる可能性があります。
そこで、日付・state・lockを使う標準Pythonの小さなガードを作り、初回、同日重複、翌日、壊れたstate、模擬中断後のレビュー停止、既存lock、cp932失敗、UTF-8成功の8ケースを固定入力で実行しました。8件とも期待どおりになりました。ただし、これはClaude Desktop自体の実機試験でも、exactly-once(厳密に1回)の証明でもありません。
完全なスクリプト、テスト、固定fixture、結果JSON、生ログ、manifestは検証教材ZIPから取得できます(14,467 bytes、SHA-256 8e8bd63bee00cacffc35408c50ebef73c7b5164e04513b9124fa35b38745888c)。実データや外部APIへは接続しません。
先に分ける:Claudeの公式仕様と、今回のPython実測
2026年10月16日にClaude Code Desktopの公式文書を再取得しました。現行の案内では、CodeタブのRoutinesからLocalの定期タスクを作ります。同じRoutines画面で作れるCloudのroutineはクラウドで動き、PCがオフでも実行できます。一方、DesktopのLocalタスクは手元のファイルへアクセスできますが、アプリが開きPCが起きている間だけ起動します。この記事は旧画面名を前提にしません。
公式文書で確認したLocalタスクの要点は次のとおりです。
- 期限になると、開いている手動セッションとは別のfresh sessionを起動する。
- アプリ起動時またはPC復帰時、直近7日以内に未実行があれば、最も新しい1回だけcatch-upし、それ以前は破棄する。
- Manual permission modeで未許可ツールが必要になると、承認まで停止し、セッションはサイドバーに残る。
- 作成後に
Run nowで試し、必要なツールをalways allowにする案内がある。ただしMCPツールのrequiresUserInteractionは毎回確認され、always-allowを選べない。
以上は公式説明です。今回、Claude Desktopでタスクを作成・起動・停止させる操作はしていません。実測したのは、その前提で外部コマンド側に置けるPythonガードだけです。Claudeの料金、契約条件、Cloud routineの実行品質も検証対象外です。
検証条件
| 項目 | 条件 |
|---|---|
| 実行日 | 2026年10月16日 |
| OS / shell | Windows / PowerShell |
| Python | 3.14.2、標準ライブラリのみ |
| 入力日 | 初日2026-10-16、翌日2026-10-17に固定 |
| 外部処理 | ローカルのeffect.jsonlへ1行追記するstub |
| state保存 | 同じdirectoryのtempへUTF-8 JSONを書き、flush・fsync・close後にos.replace |
| lock | os.O_CREATとos.O_EXCLで新規作成。既存lockは削除・上書きしない |
| 費用 | 既存のローカルPython環境だけを使い、新規支出なし |
stateは外部stubの前にrunning、stub成功の後にcompletedとして保存します。壊れたstate、running、既存lockを検出した場合はeffectを変更しません。壊れたstateはbytesとSHA-256、既存lockはbytesを前後で照合しました。
実行手順
ZIPを展開し、展開先のscheduled_task_guardで次を実行します。PowerShellのコマンドです。
python -m unittest discover -s tests -p "test_*.py" -v
python run_cases.py --output-dir reproduced
2行目が次を返せば、reproduced/case_results.jsonとreproduced/raw.logに再現結果が残ります。
{"case_count": 8, "status": "pass"}
ガードの中心は次の順序です。以下は抜粋で、このブロック単体では動きません。完全版はZIPのscheduled_task_guard.pyにあります。
# 抜粋:エラー処理、引数定義、lock解放などは省略
acquire_lock(lock_path, run_date) # O_CREAT | O_EXCL
state = load_state(state_path)
if state and state["status"] == "running":
return EXIT_REVIEW_REQUIRED
if state and state["status"] == "completed" and state["run_date"] == run_date:
return EXIT_DUPLICATE
atomic_write_json(state_path, {**identity, "status": "running"})
append_effect(effect_path, run_date, execution_id)
atomic_write_json(state_path, {**identity, "status": "completed"})
一時ファイルからstateを置換する箇所は、Python公式のos.replaceを使います。置換先と同じdirectoryにtempを作り、書き込み中のJSONを次の起動がそのまま読む範囲を狭めます。これだけで電源断時の永続性まで保証されるわけではありません。
8ケースの結果
表のeffect Δは、外部処理の代わりに置いたローカルstubへの追記行数です。pre/post-stateは見やすさのため要約し、完全値はZIPの結果JSONに残しています。
| case | pre-state | lock | command | exit | status | effect Δ | post-state |
|---|---|---|---|---|---|---|---|
| first_run | absent | absent | run 10/16 |
0 | completed |
+1 | completed 10/16 |
| same_day_duplicate | completed 10/16 | absent | run 10/16 |
10 | duplicate_skip |
0 | 不変 |
| next_day_run | completed 10/16 | absent | run 10/17 |
0 | completed |
+1 | completed 10/17 |
| corrupt_state | 不正UTF-8 JSON | absent | run 10/16 |
20 | invalid_state |
0 | bytes/hash不変 |
| crash_then_review_stop | absent | absent | crash注入→run 10/16 |
30→22 | review_required |
0 | running 10/16 |
| existing_lock | completed 10/15 | present | run 10/16 |
21 | lock_exists |
0 | state/lock不変 |
| cp932_failure | n/a | n/a | PYTHONIOENCODING=cp932 ... emit |
1 | UnicodeEncodeError |
0 | n/a |
| utf8_success | n/a | n/a | PYTHONIOENCODING=utf-8 ... emit |
0 | completed |
0 | n/a |
同日2回目のduplicate_skipと、中断後のreview_requiredを別のstatusとexitにしたのが重要です。後者は「前回が失敗した」とも「成功した」とも断定せず、人がeffectの実行IDを照合するまで止めます。既存lockも勝手に期限切れとみなして消しません。
文字コードの2ケースでは同じJalapeño チップを出力しました。PYTHONIOENCODING=cp932を明示した実行はñを表現できず終了1、utf-8を明示した実行は終了0でした。Python公式どおり、これは標準入出力のencodingを上書きする環境変数です。Windows全体やファイル本文のencodingまで直す設定ではありません。
実務でどこまで防げて、どこから人の確認が要るか
このガードで区別できたのは、固定入力に対する「完了済み」「state不正」「成否不明のrunning」「既存lock」です。同日完了済みならskipし、曖昧なら自動再実行しない、という停止境界を作れます。
一方、厳密な二重実行防止やexactly-onceは主張できません。effect追記の直後、completed保存の直前に落ちれば、外部処理は済んでいるのにstateはrunningのままです。逆に、古いlockを人が誤って消せば並行実行を許すことがあります。本番では外部サービスにも一意な冪等キーを渡し、結果を照合してから再開・完了扱い・取消を決める必要があります。
未検証・非保証の範囲は次のとおりです。
- NFSや別端末を含む分散filesystem、複数ホストの同時起動
- OSやfilesystemを横断した
os.replaceの耐障害性、directory sync、電源断、disk failure - effect成功後からcompleted保存前までのcrash window
- 外部サービスの冪等性、network timeout、retry、部分成功
- PID生存確認、lockの安全な期限切れ判定、強制終了そのもの
- Claude DesktopのLocal/Cloud taskの実行成功率、権限画面、catch-upの実機再現
無人実行前の確認リスト
- 作成直後に手動実行し、通常完了と同日skipを別々に確認する。
- stateを壊した場合に初期化せず停止し、元bytesを保てるか確認する。
runningや既存lockを自動削除せず、effectの一意IDを照合する手順を決める。- 非ASCIIを含む実データで、標準入出力とファイルencodingを別々に確認する。
- 外部サービス側の冪等キー、結果取得、retry条件を確認する。
- Claude Desktopを使う場合は
Run nowで権限を点検し、requiresUserInteractionのツールを無人前提にしない。
操作内容そのものを実行前に権限・期限・重複で止めたい場合は、AIエージェントの実行前ゲートを6ケースで検証した記事が関連します。公開前のbuildと内部リンクを点検したい場合は、Astro + Cloudflare Pagesの公開前確認を参照してください。
出典と更新履歴
- Claude Code Desktop公式文書:2026年10月16日再取得。fresh session、missed runs、Manual permission、RoutinesのLocal/Cloud差を確認。Claude実機操作は未実施。
- Python公式
os.replace:2026年10月16日確認。教材は同一directoryのtempからstateを置換。 - Python公式
PYTHONIOENCODING:2026年10月16日確認。教材ではcp932とutf-8を明示比較。
2026年10月16日更新:従来の抜粋コードと2出力だけの記録を、完全実行可能な標準Python教材へ置き換えました。8ケース、5件のunittest、結果JSON、生ログ、manifest、ZIP、結果図を追加し、現行Routines UIとCloudとの差を反映しました。Claudeの公式仕様と自作ガードの実測を分離し、exactly-onceを保証しない限界を明記しました。