0
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?

57分間、死んだジョブを誰も見ていなかった ― AIエージェントの見張りを機械に渡す

0
Last updated at Posted at 2026-09-02

この記事は note / Zenn にも同じ内容で公開しています(筆者本人による転載)。

前の記事で、AIエージェントを17時間回した実測を書きました。その最後に「見張りスクリプト、状態ファイルの設計、判断待ちの出し方は別の記事に書く」と約束したので、それを書きます。

動くコードごと出します。

まず、なぜ書くことになったか

長時間かかるジョブ(データ収集、学習、バッチ)をAIエージェントに走らせていました。私は別の作業をしながら、定期的に状態を見る監視をひとつ立てていました。

途中で、その監視が不要になったと判断して止めました。

その34分後にジョブが落ちました。誰も気づきませんでした。

次に気づいたのは57分後です。

原因は単純でした。

監視を止めた本人が、「見る責任」を誰にも渡していなかった。

**人が見張りを持つと、その人が止めた瞬間に、見る目そのものが消えます。**しかも消えたことに誰も気づきません。監視は「止めました」というログを残して、静かに終わるからです。

**だから見張りは機械に持たせます。**以下がその実装です。


① 「動いている」と「進んでいる」を分ける

最初に作った版は、プロセスが生きているかだけを見ていました。それでは足りません。

プロセスは生きている + 進んでいる    → 正常
プロセスは生きている + 進んでいない  → **固まっている**(これを見逃す)
プロセスは死んでいる + ロックは残る  → **後続が永久に待つ**
プロセスは動いていない + 動くべき    → **落ちたことに誰も気づいていない**

4つ目が57分の空白の正体です。「動いていない」だけでは異常か正常か決まりません。「動くべきなのに動いていない」を判定できないと、落ちたことに気づけません。

状態をこう分けました。

if holds and alive is False:
    state = "DEAD_LOCK_STALE"   # ロックはあるがプロセスは死んでいる
elif holds and stalled:
    state = "STALLED"           # 生きているが進んでいない
elif holds:
    state = "RUNNING"
else:
    state = "NOT_RUNNING"

## 「動いていない」だけでは足りない。**動くべきかを別に判定する**
sr, why = _should_run(job, lock)
if state == "NOT_RUNNING" and sr:
    state = "SHOULD_BE_RUNNING"

SHOULD_BE_RUNNING を足したことが、この実装でいちばん効きました。


② 進捗の出所を、1つに決めない

これはこの記事を書いている当日に見つけた欠陥です。

進捗の古さを、こう測っていました。

## 悪い実装
return file_age_sec(job.get("progress_file") or job.get("log"))

or が問題でした。progress_file があるとき、log は「予備」ではなく**「使わない」**になります。

実際に何が起きたか。

progress_file   処理が1件終わるごとに追記される。平均26秒間隔
                → ただし**大きい入力を処理中は5分を超える**
log             30秒ごとに件数を出力
                → **明確に生きていた**

停止とみなす閾値は300秒でした。進捗の古さは303秒と出ました。

健全なジョブが「停止」と判定される寸前でした。

直した版:

def _progress_age(job):
    """**進捗の出所は1つに決めない。生きている証拠が1つでもあれば生きている。**

    粒度の粗い出所だけを見ると、偽の停止が出る。
    利用できる出所すべてのうち、**最も新しいもの**を採る。
    """
    ages = []
    if job.get("progress_table"):
        ages.append(_db_progress_age(job["progress_table"]))
    for key in ("progress_file", "log"):
        if job.get(key):
            ages.append(file_age_sec(job[key]))
    ages = [a for a in ages if a is not None]
    return min(ages) if ages else None

影響が非対称なことに注意してください。

このジョブは自動再起動を禁止していたので、機械が誤って再起動することはありませんでした。しかし「停止」の赤が出ると、見張りは新しい作業を投入しなくなります。

健全なジョブについた偽の赤が、無関係な作業まで止めます。


③ プロセスの生死を、正しく測る

Windows で os.kill(pid, 0) を生存確認に使ってはいけません。

Unix では signal 0 は「何もしない」ですが、Windows の os.killTerminateProcess を呼びうるため、確認するつもりで殺します。

def _pid_alive(pid):
    """Windows で pid の生存を見る。**os.kill は使わない**"""
    if not pid:
        return None
    try:
        import ctypes
        k = ctypes.windll.kernel32
        h = k.OpenProcess(0x1000, False, int(pid))   # QUERY_LIMITED_INFORMATION
        if not h:
            return False
        code = ctypes.c_ulong()
        ok = k.GetExitCodeProcess(h, ctypes.byref(code))
        k.CloseHandle(h)
        return bool(ok) and code.value == 259        # STILL_ACTIVE
    except Exception:
        return None

戻り値を3値にしています。True / False / None(判定できなかった)。

**「判定できなかった」を False に丸めないでください。**丸めると、確認に失敗しただけのジョブを「死んだ」と扱い、再起動して二重に走らせます。


④ 状態ファイル ― 何を宣言させるか

**ジョブごとに「進んでいる証拠がどこに出るか」が違います。**それを見張り側にハードコードすると、ジョブが増えるたびに見張りを直すことになります。

宣言させます。

jobs:
  - id: collect_raw
    name: 生データの一括取得
    owner: data-team
    log: /var/log/collect.log
    progress_file: /data/raw/_manifest.jsonl
    stall_sec: 300
    expected_total_files: 5121

    # **機械が再開してよいか。既定は false**
    auto_restart: false
    auto_restart_blocked_reason: >-
      サーバ側エラーの原因が未特定。原因を見ずに再開すると、
      配信側の異常を「順調」と読み続けることになる。

    # **「走るべきなのに走っていない」を機械が判定するための条件。**
    # 機械が評価できるものだけを書く
    should_run_when:
      - not_complete        # 進捗の行数 < 期待総数
      - resource_free       # 排他資源を生きたプロセスが握っていない

設計の要点は1つです。

should_run_when を書けないジョブは、見張りが「落ちた」と判定できない。

だから**書けないジョブは登録させません。**登録の条件にすることで、「見ているつもり」の状態がなくなります。


⑤ 機械が再開してよい条件、いけない条件

ここが最も間違えやすいところでした。

私は一度、サーバ側エラーで落ちたジョブを**自動で再開する設計にしていました。**壊れた入力を飛ばして続行する、という実装です。

もし自動で再開していたら、飛ばした期間は永久に欠測になっていました。

原因を調べる過程で、使っていたSDKに**「壊れたキャッシュを削除する関数」**があることが分かりました。**削除すれば次回に再取得されます。**つまり正しい復旧は「飛ばす」ではなく「消して取り直す」で、欠測は出ませんでした。

人を挟んで遅れる      数十分〜半日
挟まずに間違える      **復旧不能な欠測**
                      配信側の異常を「順調」と読み続ける

後者のほうが高くつくので、迷ったら人を挟みます。

**「早く再開する」ことと「正しく再開する」ことは別です。**自動再開で得られたのは数十分の短縮、失うところだったのは復旧可能なデータそのものでした。

規則にしました。

機械が再開してよいのは
  ・**原因が特定済みで、対処が手順として確定している**
  ・かつ設定に auto_restart: true と、その根拠が書かれている
  ・かつ 1日の再開回数に上限がある(既定6回)

それ以外は、見張りは「再開が必要」と表示して**止まる。**

「繰り返し落ちている」は、手順ではなく原因を見るべき合図です。


⑥ 判断待ちの出し方

見張りが見つけたものを、全部その場で人に投げると、人が見なくなります。

指示の順番を固定しました。

RESTART  →  RED  →  STALE  →  DISPATCH

赤(検査の失敗)があるうちは、新しい作業を投入しません。

**検査を書くこと自体は事故を防ぎません。赤を放置しないことだけが防ぎます。**赤があるまま次を積むと、赤は「いつもの表示」になって消えます。

人に上げるものも絞りました。

即時に上げる(これだけ)
  ① 人の操作でしか進まないもの
  ② 不可逆な操作の直前(削除・上書き・公開・送信・課金)
  ③ 守るべき規則が破れている状態を見つけたとき
  ④ 止まっていて、機械では再開できないとき

それ以外   ファイルに溜めて、1日1回まとめて出す

判断待ちを随時投げると、人の時間が細切れになって、結局どれも決まりません。


まとめ

① 「動いている」と「進んでいる」を分ける
   さらに「動くべきなのに動いていない」を別に判定する
② 進捗の出所は1つに決めない。**最も新しいもの**を採る
③ 生死は3値(生・死・不明)。不明を死に丸めない
④ 「進んでいる証拠がどこに出るか」はジョブに宣言させる
   宣言できないジョブは登録させない
⑤ 機械が再開してよいのは、原因が分かっていて手順が確定している場合だけ
⑥ 赤があるうちは新しい作業を投入しない

**この6つは、全部いちど失敗してから足したものです。**最初から思いついたものは1つもありません。

そして、いちばん大事なのはこれでした。

見張りは機械が持つ。人が持つと、その人が止めた瞬間に消える。

**人が持つべきなのは判断だけです。**生成はAIに、検証と見張りは機械に、判断を人に。


この連載

AIエージェントを実際に回して、かかった費用と壊れた箇所を測って書いています。推測は書きません。実測値だけです。

  1. Claude Code のトークン使用量を実測したら1ターン23万 ― 節約に効いたのは「セッションを切る」だった
    https://qiita.com/manabu49-ai/items/2598a30d5140e4445ab6
  2. 本記事 ― 57分間、死んだジョブを誰も見ていなかった ― AIエージェントの見張りを機械に渡す
  3. AIに100通り試させたら、効果ゼロなのに5個が「有意」だった
    https://qiita.com/manabu49-ai/items/397a018948e1ab7ef29e
  4. 仕様書は2日前から手元にあった ― 読まずに実装した対処が、データを永久に失う設計だった
    https://qiita.com/manabu49-ai/items/9b947604d3dc4958fa36
  5. 次 ― AI初心者が3日でエージェントを24時間回すまで(未公開)

番外 ― Claude のモデル提供終了、猶予は告知から約60日 ― 過去9回を数えて、手元を検査するスクリプトを書いた
https://qiita.com/manabu49-ai/items/bc2a7be2a6ee4ceb6185

**無料記事は結論まで全部書きます。**出し惜しみはしません。

この記事は note / Zenn にも同じ内容で公開しています(筆者本人による転載)。

0
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
0
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?