定期実行しているはずの処理が、ログ上はずっと「SUCCESS」なのに、出力フォルダを開くと先週から一つもファイルが増えていない——個人開発で複数の Cowork スケジュールタスクを無人で回していると、この種の事故に必ず一度はぶつかります。私自身、ある朝にまとめてログを見返したとき、三日連続で「成功」と記録された定期処理が、実際には一行も書き込めていなかったことに気づいて背筋が冷えました。
厄介なのは、これがエラーで止まっていないことです。例外は飛んでいない。終了コードは 0。スケジューラの履歴も緑色。それでも成果物はゼロ。ここでは、この「無音失敗(silent success)」を終了コード任せにせず、完了条件そのものをアサーションに落として赤くする設計をまとめます。
終了コードは「成果が出たこと」を保証しない
私たちは無意識に「exit 0 = うまくいった」と読み替えています。けれど終了コードが保証するのは「最後に走ったコマンドが 0 を返した」ことだけで、「本来やるべき仕事が完了した」ことではありません。この二つはまったく別の命題です。
無音失敗が生まれる典型的な経路は、だいたい次の三つに集約されます。
| 経路 | 何が起きるか | なぜ exit 0 のままか |
| 書き込み先のすれ違い | ファイルを生成したつもりが、古い一時パスや存在しないディレクトリに書いていた | cat > file 自体は成功する。中身が意図した場所に無いだけ |
| 前提データの空振り | 参照すべき入力ファイルのパスを間違え、空文字列を元に処理が進む | cat 間違ったパス はエラーにならず空を返す。後続も「空に対する正常処理」 |
| コミットの不成立 | git の identity 未設定などで commit が無言で成立せず、push は「最新です」で緑になる | push する差分が無いので push 自体は成功扱い |
どれも共通しているのは、個々のコマンドは正直に 0 を返しているという点です。嘘をついているコマンドは一つもありません。にもかかわらず全体としては失敗している。だからこそ、終了コードを上から眺めているだけでは永遠に気づけないのです。
私が最初にこれを踏んだのは、git の identity を設定し忘れたクローン直後のリポジトリでした。git commit は警告だけ出して実質何もせず、git push は「Everything up-to-date」と返してくる。スケジューラの履歴は三日間きれいな緑のまま、リモートには一行も増えていませんでした。
完了条件を言葉で書き出す
無音失敗を潰す第一歩は、コードを書くことではありません。「この処理が成功したと言える状態」を具体的な観測可能事実として言語化することです。ここが曖昧なままだと、何をアサートすればいいのかも決まりません。
たとえば「生成物をリポジトリに反映する」定期処理なら、完了条件は次のように分解できます。
- 出力ファイルが実際に存在し、サイズが下限を超えていること
- 期待する件数と実ファイル数が一致していること(日英セットなら左右が揃っている)
- ローカルのコミット SHA が、push 前の値から変化していること
- リモートの SHA とローカルの SHA が一致していること
ポイントは、どれも「やったつもり」ではなく外から確かめられる事実だということです。「commit した」ではなく「SHA が変わった」。「ファイルを書いた」ではなく「そのパスに下限サイズ以上のファイルがある」。この言い換えができれば、アサーションは自然と書けます。
終了前アサーション・ハーネスを挟む
完了条件が決まったら、処理の最後にそれを機械的に確認する関門を一つ置きます。一つでも崩れていたら、成功ログを書かせずに非ゼロで落とす。これだけで無音失敗は表に出てきます。
#!/usr/bin/env bash
# verify_done.sh — 定期処理の「完了条件」を終了前に検証するハーネス
# 使い方: 処理の最後に source して assert_* を並べ、finish_run で締める
set -uo pipefail
EVIDENCE_LOG="${EVIDENCE_LOG:-/tmp/run_evidence_$(date +%s).log}"
FAILED=0
# 失敗を「握りつぶさず」記録する。第1引数=条件名, 第2引数=実測値の説明
fail() {
FAILED=1
printf '[FAIL] %s | %s\n' "$1" "$2" | tee -a "$EVIDENCE_LOG" >&2
}
pass() {
printf '[ OK ] %s | %s\n' "$1" "$2" | tee -a "$EVIDENCE_LOG"
}
# 条件1: ファイルが存在し、下限バイト数を超えている
assert_file_min() {
local path="$1" min="${2:-200}"
if [ ! -f "$path" ]; then
fail "file_exists" "missing: $path"; return
fi
local size; size=$(wc -c < "$path")
if [ "$size" -lt "$min" ]; then
fail "file_min_size" "$path = ${size}B (< ${min}B)"; return
fi
pass "file_min_size" "$path = ${size}B"
}
# 条件2: 二つの件数が一致している(例: 日英セット)
assert_count_match() {
local label="$1" a="$2" b="$3"
if [ "$a" != "$b" ]; then
fail "count_match:$label" "left=$a right=$b"; return
fi
pass "count_match:$label" "$a == $b"
}
# 条件3: コミット SHA が事前値から変化している
assert_sha_changed() {
local before="$1" after="$2"
if [ "$before" = "$after" ]; then
fail "sha_changed" "commit did not advance: $after"; return
fi
pass "sha_changed" "$before -> $after"
}
# 締め: 一つでも FAIL があれば非ゼロで落とす
finish_run() {
if [ "$FAILED" -ne 0 ]; then
printf '\n🛑 完了条件を満たしていません。SUCCESS を記録せず終了します。\n' >&2
printf ' 証拠ログ: %s\n' "$EVIDENCE_LOG" >&2
exit 1
fi
printf '\n✅ 全ての完了条件を満たしました。\n'
}
呼び出し側はこうなります。SHA_BEFORE を push の前に控えておくのが肝心で、これが無いと「変化したか」を後から判定できません。
source verify_done.sh
OUT="content/articles/ja/cowork/example.mdx"
SHA_BEFORE=$(git rev-parse HEAD)
# ... ここで生成・コミット・push ...
SHA_AFTER=$(git rev-parse HEAD)
REMOTE_SHA=$(git rev-parse '@{u}')
assert_file_min "$OUT" 800
assert_count_match "ja_en" \
"$(find content/articles/ja -name '*.mdx' | wc -l)" \
"$(find content/articles/en -name '*.mdx' | wc -l)"
assert_sha_changed "$SHA_BEFORE" "$SHA_AFTER"
assert_count_match "local_remote_sha" "$SHA_AFTER" "$REMOTE_SHA"
finish_run # ← ここで初めて成功か失敗かが確定する
この構造の良いところは、成功ログを書く権利を finish_run が握っていることです。途中のどこかが崩れていれば、その先のログ記録には絶対に到達しません。「やったつもり」でログだけ進む、という最悪の経路が物理的に塞がれます。私はこの場面では、生成の本体がどれだけ整っていても、終了前アサーションを必ず一つ挟むことを推奨します。
なぜ「握りつぶさない」ことが核心なのか
set -e(エラーで即終了)を付ければいいのでは、と思うかもしれません。けれど無音失敗の多くは、そもそもエラーになっていないので set -e では捕まりません。空の cat も、差分の無い push も、終了コードは 0 だからです。
set -e が守ってくれるのは「コマンドが明示的に失敗したとき」だけで、「コマンドは成功したが成果が出ていないとき」は素通りします。だから対策の方向は「エラーを伝播させる」ではなく、**「成果が出たことを積極的に確かめる」**でなければなりません。証明責任を処理側に負わせる、という発想の転換です。
これは Anthropic の内部スキルにある verification-before-completion(完了前の検証)の考え方とも重なります。「終わったと宣言する前に、終わった証拠を出せ」という規律です。証拠ログ(EVIDENCE_LOG)を必ず残すのは、後から「なぜ赤くなったのか」を人間が追えるようにするためでもあります。無音失敗は再現が難しいので、失敗の瞬間の観測値を残しておくことが何よりの資産になります。
二重生成・部分成功・空振りを切り分ける
無音失敗を真面目に潰そうとすると、隣接する二つの状態にも手当てが要ります。終了前アサーションは「成果ゼロ」を捕まえますが、運用ではもう少し細かい区別が必要です。
| 状態 | 症状 | 対処 |
| 空振り成功 | exit 0 だが成果物が無い | 終了前アサーション(本記事の主題) |
| 部分成功 | 一部だけ生成され、途中で力尽きた | 件数一致アサーション+途中生成物のクリーンアップ |
| 二重生成 | 再実行で同じ成果物が二つできる | 冪等キーで「既にやったか」を先に確認 |
二重生成は、リトライと相性が悪い無音失敗対策の落とし穴です。アサーションで赤くして再実行する設計にすると、今度は「前回は実は半分成功していた」ケースで重複が生まれます。これを避けるには、処理の冒頭に冪等キーのチェックを置きます。
import hashlib
import os
def idempotency_key(*parts: str) -> str:
"""入力の組み合わせから安定したキーを作る。同じ入力なら同じキー。"""
raw = "\x1f".join(parts).encode("utf-8")
return hashlib.sha256(raw).hexdigest()[:16]
def already_done(key: str, ledger: str = ".run_ledger") -> bool:
"""この入力を以前に完了済みなら True。台帳に1行1キーで記録する。"""
if not os.path.exists(ledger):
return False
with open(ledger, encoding="utf-8") as f:
return any(line.strip() == key for line in f)
def mark_done(key: str, ledger: str = ".run_ledger") -> None:
with open(ledger, "a", encoding="utf-8") as f:
f.write(key + "\n")
# 使い方: 生成対象を一意に表す入力からキーを作る
key = idempotency_key("cowork", "2026-06-27", "example-topic")
if already_done(key):
print(f"⏭ 既に完了済み: {key} — スキップします")
else:
# ... 生成処理 ...
mark_done(key) # 完了条件を満たした「後で」だけ台帳に書く
print(f"✅ 完了を記録: {key}")
mark_done を呼ぶ位置が重要です。生成の途中ではなく、終了前アサーションを通過した後に書きます。こうすると「アサーションで赤くなった実行」は台帳に残らないので、次回のリトライが正しくやり直してくれます。逆に生成の冒頭で台帳に書いてしまうと、途中で力尽きたときに「完了済み」と誤記録され、永遠にスキップされる新たな無音failureを生みます。
ログ自体を信用しすぎない
最後に運用上の小さな、しかし効く工夫を一つ。成功ログには件数や SHA といった実測値を必ず添えることです。「SUCCESS」とだけ書かれたログは、人間が後から見ても無音失敗を見抜けません。
[13:02 JST] cowork-example
Status: SUCCESS
Files: ja=684 en=684 (match)
Commit: a1b2c3d -> e4f5g6h
Remote: e4f5g6h (in sync)
このログなら、たとえアサーションをすり抜ける未知の経路があったとしても、人間が数字の異常に気づけます。Files: ja=683 en=683 が何日も同じ数字で並んでいたら、それ自体が無音失敗のサインです。機械の関門と人間の目視、二段構えにしておくと安心感が違います。
私はこの設計に切り替えてから、定期処理のログを朝に流し見する習慣そのものの意味が変わりました。以前は「緑だから大丈夫」と眺めるだけだったのが、今は「数字が動いているか」を見るようになりました。緑であることと、仕事が進んでいることは、別物だと身体で覚えたからです。
まず手元の定期処理を一つ選んで、その「完了したと言える状態」を三つ、観測可能な事実として書き出してみてください。アサーションに落とすのは、その後で十分間に合います。