Python のスクリプトを launchd や cron で定期的に動かしている人に向けた、失敗の記録です。書き込みに失敗していたのに、終了コード0で終わっていた仕事の話です。
何が起きたか
私の Mac では、投稿の原稿を AI に書かせる仕事を、launchd の時刻指定で毎朝動かしていました。お店の情報に載せる投稿の原稿(題と本文)を、その月の月・水・金の分だけ作ります。2026年8月なら月・水・金は13日あるので、13本です。
流れは次のとおりです。
- 日付から、その月の投稿日を数える
- AI に題材を考えさせる
- AI に原稿を書かせる
- Google スプレッドシートに書き込む
表に入った原稿は、別の自動化の道具が投稿として出します。
2026年8月18日の朝、フタを閉じて寝たせいで朝の仕事がまとめて遅れて動いた日がありました。その遅れた仕事のログを読んでいて、この仕事の4つ目、表への書き込みで失敗している行を見つけました。実行は書き込みの所まで進み、そこで通らずに終わっていました。表に原稿は入っていませんでした。
いつから続いていたかは分かりません。この Mac で動かし始めたのが前日の8月17日で、それより前のログが残っていないためです。
なぜ見えなかったか
例外を握りつぶして exit 0
この仕事は、書き込みに失敗するとログに1行書き、そのまま普通に終わる作りでした。プロセスの終了コードは0です。形としてはこういうものです(実物ではなく、一般化した例です)。
def main():
drafts = make_drafts()
try:
write_to_sheet(drafts)
except Exception as e:
logger.error(f"シートへの書き込みに失敗: {e}")
# 何も返さないので、プロセスは 0 で終わる
if __name__ == "__main__":
main()
ログには失敗が残ります。ただ、ログは誰かが読みに行かない限り、何も起こしません。
スケジューラーは終了コードしか見ない
launchd も cron も、仕事の中身が正しかったかは知りません。分かるのは、プロセスがどの終了コードで終わったかです。0で終われば、成功として扱われます。launchd なら launchctl list に前回の終了状態が出ますが、0なら目立つものは何もありません。
この件では、仕事は0を返し、スケジューラーから見れば成功で、エラーの知らせも来ませんでした。私自身も、表に原稿が入ったかを確かめていませんでした。確かめる仕組みが、そもそも無かったのです。
認証の行き止まり(invalid_grant)
書き込めなかった原因は、表に書き込むための認証の更新が通らなくなっていたことでした。エラーには invalid_grant と出ていました。
一般論として、OAuth 2.0 の invalid_grant は、トークンを取り直すときに渡した許可(リフレッシュトークンなど)が受け付けられなかったことを示すエラーです。取り消された、期限が切れた、などいくつかの理由で返り、エラーだけではどれなのか分からないことがあります。
私の環境では、さらに事情がありました。その認証の鍵は、もともと別の自動化の道具のために用意したもので、手元のスクリプトから認証をやり直せない作りでした。更新が通らなくなった時点で、行き止まりです。その状態のまま、仕事は終了コード0で終わっていました。
直したのは、人が定期的に認証し直さなくてよい方式(サービスアカウント)への差し替えです。あわせて、書き込む表をそのアカウントに共有し、API を有効にしました。ただ、これで二度と止まらないわけではありません。権限や鍵の設定が変われば、また書き込めなくなることはありえます。
実行の頻度の設計ミス
もう1つの問題は頻度です。13本は、ひと月分の原稿です。必要なのは月に1回なのに、毎朝動く設定でした。
この設定のまま毎朝動けば、13本×30日で、30日に390本を作る計算になります。必要なのは13本です。これは試算で、実際に何日動いて何本作ったか、AI にいくら使ったかは記録に残っていません。
もっと前は15分ごとに動く設定で、AI の予算をひと月で使い切ったため毎朝1回にした、と記憶しています(記録ではなく私の記憶です)。頻度を減らしたとき、必要な回数まで減らし切っていなかったことになります。
今は毎月1日の1回にしています。launchd なら StartCalendarInterval に Day を入れれば月1回になります(時刻は例です)。
<key>StartCalendarInterval</key>
<dict>
<key>Day</key><integer>1</integer>
<key>Hour</key><integer>9</integer>
<key>Minute</key><integer>0</integer>
</dict>
cron なら 0 9 1 * * です。
直し方の考え方
失敗したら0以外で終わる
例外を拾ってログに書くのはよいとして、最後は0以外を返します。
import sys
def main() -> int:
drafts = make_drafts()
try:
write_to_sheet(drafts)
except Exception:
logger.exception("シートへの書き込みに失敗")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
拾わずに例外をそのまま上げても、Python は0以外で終わります。拾うなら、拾ったあとに終了コードで返す。要点はそこです。
私の別の仕事では、記事を投稿する処理は最後の段の終了コードをそのまま返し、また別の仕事は、出せなかった枠が1つでもあれば1で終わります。一方、この原稿の仕事は、今も失敗をログに書いて0で終わる形のままです。月1回になり認証も差し替えたので今のところ困っていませんが、直す対象として残っています。
結果の側を数える見張りを別に置く
終了コードを正しく返しても、それだけでは足りない場面があります。0以外を返したことに誰も気づかなければ同じですし、正常に終わったのに中身が空ということもありえます。
そこで、仕事そのものとは別に、結果の側を数える見張りを置く考え方をとっています。仕事が動いたかではなく、出るはずのものが実際に出たかを数え、足りないときだけ知らせます。私の所では、記事や投稿が出ているかを1日1回これで見ています。
いまは月の初めに、ログの最後まで進んだかを見ているだけで、表に入った本数までは数えていません。次に足すのは、月の初めに、その月の投稿日の数だけ原稿が表に入ったかを数えることです。月・水・金の数は月によって変わるので(2026年なら2月は12日、7月は14日)、13本と決め打ちにはしません。
頻度は必要な回数に合わせる
作る量と、実際に使う量を並べて見直します。ひと月に使うのが13本なら、作るのも月1回で足ります。
まとめ
- 例外を拾ったら、最後に0以外で終わる
- スケジューラーが知っているのは終了コードだけ。出たかどうかは別の仕組みで数える
- 認証の更新が通らなくなったとき、手元でやり直せる作りかを確かめておく
- 実行の頻度は、必要な回数に合わせる
この件を含む自動化の失敗の記録は note にも書いています:https://note.com/vellum_office