1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude CodeのMonitorツールで「止まっている自動化」を検知する — ポーリングを捨てた32ジョブ運用

1
Posted at

僕は一人会社のCEOで、launchdで32個の自動化ジョブを回しています。記事生成、SNS配信、KDP売上取得、Zennデプロイ。人を雇わない代わりに、スクリプトが勝手に働く構成です。

この構成には一つだけ致命的な欠陥がありました。ジョブは「動いているつもり」で静かに止まる。2026年9月、当社ではZennデプロイが3日間失敗し続け、誰も気づきませんでした。pushは成功していたからです。

この記事は、その検知を「定期ポーリング」からClaude CodeのMonitorツールとスケジュール基準のウォッチドッグに置き換えた実装の記録です。コードはすべて当社で現に動いているものです。


1. Monitorツールとは何か(そして何が嬉しいか)

Claude CodeのMonitorは、バックグラウンドで走らせたタスクやログの状態を、条件が満たされるまで待つためのツールです。従来こう書いていた処理を、

# アンチパターン: エージェントに何度も起こされるポーリング
while true; do
  if grep -q "DONE" build.log; then break; fi
  sleep 30
done

エージェント側のsleepループなしで書けます。ポイントは3つ。

  1. 前景のsleepはそもそも禁止されている(Claude Codeの実行環境ではブロックされます)。待機はMonitorかバックグラウンド実行で表現する。
  2. run_in_background: trueで起動したBashコマンドは、終了時にエージェントが自動で再呼び出しされる。だから「終わったか確認するための5分おきの起床」は純粋な無駄です。
  3. 監視すべきはプロセスの生死ではなくログの鮮度。ここが本題です。

実際の運用手順は次の通り。長時間ジョブはバックグラウンドで投げます。

# 例: 全社監視スクリプトをバックグラウンド実行
bash /Users/kyoagun/workspace/one-ceo/.company/scripts/auto-monitor.sh
# → run_in_background: true で起動し、完了時に通知が返る

そのうえで、「何をもって異常とみなすか」を判定するロジックをスクリプト側に持たせる。エージェントに毎回考えさせない。ここを外すと、監視そのものがコスト源になります。


2. 実装その1: ヘルスチェック + 自動修復(auto-monitor.sh)

当社で1日2回(6:30 / 15:00 JST)走っている監視スクリプトの中核部分です。特徴は検知したらその場で直すこと。通知だけ飛ばす監視は、結局人間のタスクを増やします。

#!/bin/bash
# AI-CEO 全社監視・自動修復スクリプト
# launchd: com.joinclass.auto-monitor(毎日6:30 + 15:00 JST)
set -uo pipefail

# launchd はログインシェルの PATH を継承しない。ここが最大の落とし穴。
export PATH="/Users/kyoagun/.nvm/versions/node/v22.17.0/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export HOME="/Users/kyoagun"

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
LOG_FILE="$SCRIPT_DIR/logs/auto-monitor.log"

ISSUES=""; FIXES=""; ISSUE_COUNT=0
log()       { echo "$(date): $1" >> "$LOG_FILE"; }
add_issue() { ISSUES="$ISSUES\n:x: $1"; ISSUE_COUNT=$((ISSUE_COUNT + 1)); }
add_fix()   { FIXES="$FIXES\n:wrench: $1"; }

# --- ダッシュボード稼働チェック → 落ちていたら launchd で再起動 ---
if ! curl -sf -m 30 http://localhost:4000/api/health >/dev/null 2>&1; then
  add_issue "ダッシュボード(localhost:4000)が停止"
  PLIST="/Users/kyoagun/Library/LaunchAgents/com.joinclass.ai-ceo-dashboard.plist"
  launchctl unload "$PLIST" 2>/dev/null
  sleep 2
  launchctl load "$PLIST"
  sleep 10
  if curl -sf -m 30 http://localhost:4000/api/health >/dev/null 2>&1; then
    add_fix "ダッシュボードを自動再起動 → 復旧完了"
  else
    add_issue "ダッシュボード再起動に失敗。手動対応が必要"
  fi
fi

# 問題があるときだけ通知する(正常通知は読まれなくなるので出さない)
if [ "$ISSUE_COUNT" -gt 0 ]; then
  log "問題 ${ISSUE_COUNT} 件を検知"
  # ここで Slack / 通知へ
fi

落とし穴3つ(全部踏みました)。

  • set -eを付けない。set -uo pipefail止まりにしてあるのは、チェックの1つが失敗しても残りを走らせたいから。監視スクリプトで-eは事故です。
  • export PATHを必ず書く。launchd/cron配下ではnodeもghも見つかりません。「手元では動くのに定時実行だけ落ちる」の9割はこれ。
  • curlに-m(タイムアウト)を必ず付ける。付け忘れると監視スクリプト自体がハングして、監視が死んだことに誰も気づかないという最悪の状態になります。

検証はこれだけで済みます。

# ダッシュボードをわざと落として、自己修復するか確認する
launchctl unload ~/Library/LaunchAgents/com.joinclass.ai-ceo-dashboard.plist
bash .company/scripts/auto-monitor.sh
tail -20 .company/scripts/logs/auto-monitor.log
curl -sf http://localhost:4000/api/health && echo "復旧OK"

3. 実装その2: 「ログの古さ」ではなく「スケジュールに照らした鮮度」で判定する

ここが本記事で一番伝えたい設計です。

素朴なウォッチドッグは「ログが24時間更新されていなければ異常」と書きます。しかしこれは破綻します。毎月1日だけ走るジョブは、24日前のログでも正常だからです。閾値を緩めれば、毎時ジョブの停止を見逃す。

解は、閾値をplist(=スケジュール定義そのもの)から実行時に読むことです。設定を二重管理しない。

#!/usr/bin/env python3
"""自動化ジョブのウォッチドッグ — 止まっているジョブを検知する。

判定は**ログの古さではなく、そのジョブのスケジュールに照らした鮮度**で行う。
auto-agent-health は毎月1日実行なので24日前のログでも正常、という区別が必要。
"""
import plistlib
from datetime import datetime, timedelta, timezone
from pathlib import Path

JST = timezone(timedelta(hours=9))
PROJECT_ROOT = Path("/Users/kyoagun/workspace/one-ceo")
LOG_DIR = PROJECT_ROOT / ".company/scripts/logs"
PLIST_DIR = Path.home() / "Library/LaunchAgents"

# スケジュールなし・意図的に停止中のジョブ(検査対象外)
EXCLUDED = {
    "ai-ceo-dashboard",   # 常駐プロセスでログ更新が不定期
    "retry-zenn-deploy",  # 障害時のみ実行
    "auto-lead-collect",  # 2026-09-17 CEO判断で停止
}


def tolerance_from_interval(cal) -> timedelta:
    """StartCalendarInterval から「許容される最大経過時間」を導く。

    Month 指定 → 月次、Day 指定 → 月次、Weekday 指定 → 週次、
    それ以外(時刻のみ)→ 日次。実行間隔の2倍を許容し、1回の失敗では鳴らさない。
    """
    if "Month" in cal or "Day" in cal:
        return timedelta(days=62)
    if "Weekday" in cal:
        return timedelta(days=14)
    return timedelta(days=2)


def job_schedules() -> dict[str, timedelta]:
    """plist から {ジョブ名: 許容される最大経過時間} を作る。"""
    out = {}
    for p in sorted(PLIST_DIR.glob("com.joinclass.*.plist")):
        name = p.name.replace("com.joinclass.", "").replace(".plist", "")
        if name in EXCLUDED:
            continue
        with p.open("rb") as f:
            data = plistlib.load(f)
        cal = data.get("StartCalendarInterval")
        if isinstance(cal, dict):
            out[name] = tolerance_from_interval(cal)
        elif isinstance(cal, list):
            # 1日複数回実行。最も短い間隔ではなく日次として扱えば十分
            out[name] = timedelta(days=2)
        elif "StartInterval" in data:
            out[name] = timedelta(seconds=data["StartInterval"] * 3)
    return out


def stale_jobs() -> list[str]:
    now = datetime.now(JST)
    problems = []
    for name, tolerance in job_schedules().items():
        log = LOG_DIR / f"{name}.log"
        if not log.exists():
            problems.append(f"{name}: ログが存在しない(一度も実行されていない可能性)")
            continue
        age = now - datetime.fromtimestamp(log.stat().st_mtime, JST)
        if age > tolerance:
            problems.append(f"{name}: {age.days}日間更新なし(許容 {tolerance.days}日)")
    return problems


if __name__ == "__main__":
    for line in stale_jobs():
        print(line)

実際のスクリプトには、ログ名とジョブ名が一致しないケースの吸収も入れてあります。これも実運用で必ず出る問題です。

# ログ名がジョブ名と一致しないもの。値がパス区切りを含む場合は PROJECT_ROOT からの相対。
# note-publish は stdout を別ログへ流すため、plist の StandardOutPath は空のままになる。
LOG_ALIAS = {
    "ai-ceo-dashboard": "dashboard",
    "publish-automation-bible": "publish-book",
    "note-publish": ".company/scripts/note/post-note.log",
    "note-like": ".company/scripts/note/like-notes-api.log",
    "retry-zenn-deploy": None,  # 一度限りの復旧ジョブ。常駐しない
}

検証方法

デプロイ前に、わざと古いログを作って鳴るかを確認します。監視の監視をしないと、静かな監視が出来上がります。

# 3日前のタイムスタンプを偽装して検知されるか確認
touch -t "$(date -v-3d +%Y%m%d%H%M)" .company/scripts/logs/auto-sns.log
python3 .company/scripts/auto-watchdog.py
# → "auto-sns: 3日間更新なし(許容 2日)" が出れば正常

# 逆に月次ジョブが誤検知されないことも確認する
touch -t "$(date -v-24d +%Y%m%d%H%M)" .company/scripts/logs/auto-agent-health-check.log
python3 .company/scripts/auto-watchdog.py | grep agent-health && echo "誤検知あり" || echo "OK"

4. Monitorとウォッチドッグの役割分担

両方入れて分かったのは、時間軸が違うということです。

Monitorツール ウォッチドッグ(定時)
対象 いま走らせた単発のジョブ 常設の32ジョブ全体
時間軸 秒〜分 日〜月
検知するもの 完了・失敗・条件成立 沈黙(実行されていないこと)
起動主体 エージェント launchd

Monitorは「投げた処理の完了を待つ」ため、ウォッチドッグは「投げられなかった処理を見つける」ため。当社のZennデプロイ3日間停止は後者で、前者だけでは永遠に検知できませんでした。エラーが出ていないことは、正常であることを意味しません。


5. まとめ

  • 監視スクリプトにset -eとcurlのタイムアウト省略は禁物。監視自体が静かに死ぬ
  • launchd配下ではexport PATHを明示。定時実行だけ落ちる原因の大半がこれ
  • 閾値はplistから実行時に導出する。スケジュールの二重管理をやめると、ジョブを増やしても監視が壊れない
  • 検知したらその場で直す。通知だけの監視は人間のタスクを増やすだけ
  • Monitorは「完了待ち」、ウォッチドッグは「沈黙の検知」。守備範囲が違う

経営の観点で言うと、一人会社で32ジョブを回せるかどうかは、コードの量ではなく止まったことに気づける仕組みがあるかで決まりました。自動化の本体は実行ではなく検知です。


参考書籍

launchd/cronでの常設自動化、Hooks、通知設計まで一通りまとめたものが当社の書籍にあります。

  • 『Claude Code 全自動化バイブル』— Hooks・cron・常設ジョブ設計と、本記事のウォッチドッグを含む自動化運用の全体像

書籍一覧: https://zenn.dev/joinclass?tab=books

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?