DEV Community

sai-builder
sai-builder

Posted on • Edited on

自律エージェントを止めずにアップデートする — SIGHUP・plan.json ホットリロード・無停止デプロイの実装

結論

  • 24h動いてる自律エージェントを毎回止めて再起動するのは負け筋。状態が消え、ログが切れ、API レート再カウントが走る
  • 止めずに直す仕組みは3つ:SIGHUP で設定再読込/plan.json のホットリロード/実行コードはサブプロセス分離
  • Python 標準ライブラリだけで組める。替えていい場所と、再起動でしか替えられない場所を先に決めるのがコツ

前回(5つの仕組み)の続編。daemon を動かしたままコードを差し替える手順を残す。

なぜホットリロードが要るか

最初は systemctl restart で十分と思ってた。が24h回すと再起動コストがのしかかる。サイクルが途中で死ぬ。外部 API を叩いた直後で死ぬと「叩いたけど書いてない」が残る。MCP 再接続込みで起動に30〜60秒。1日3〜4回直すと実稼働より「立ち上げ中」が長い日ができる。止めずに直す方が圧倒的にラク。

どこを替えて、どこは諦めるか

全部を無停止は無理importlib.reload で差し替えてもインスタンス化済みのオブジェクトが古いクラスを掴んだままで整合性が崩れる。替える対象を3層に分けた。

レイヤー 替え方
設定値 API キー、interval、しきい値 SIGHUP で再読込
計画ファイル plan.json mtime 監視で自動リロード
実行コード エージェント実装本体 サブプロセスごと差し替え
コア daemon.pyscheduler.py 再起動

コアは固定、上に乗ってるものは全部差し替え可。コア修正のときだけ素直に再起動する。月1〜2回。

1. SIGHUP で設定だけ再読込

SIGHUP は UNIX 伝統の「設定を読み直せ」シグナル。Nginx もこれ。

import signal, json, threading
_config, _lock = {}, threading.Lock()

def load_config(path="config.json"):
    global _config
    with open(path, "r", encoding="utf-8") as f:
        new_cfg = json.load(f)
    with _lock:
        _config = new_cfg

def install_sighup_handler(path="config.json"):
    def _handler(signum, frame):
        try: load_config(path)
        except Exception as e:
            print(f"[config] reload failed: {e}")
    signal.signal(signal.SIGHUP, _handler)
    load_config(path)
Enter fullscreen mode Exit fullscreen mode

使い方は kill -HUP <pid>。肝は2点。ロックで原子的に差し替える失敗しても旧設定で動かす。Windows ネイティブは SIGHUP 無しなので mtime 監視で代用。

2. plan.json のホットリロード

実装は os.stat().st_mtime を別スレッドでポーリング。差分が出たら再ロード→topo_sort で循環依存をその場で弾く→ロック越しに plan を差し替える。失敗時は旧 plan を保持。

ポイントは state(last_run 等のランタイム情報)を plan から切り離すこと。plan.json から agent を消しても state[agent_X] は別 dict に残す。同じ id で戻ったとき last_run を継承できる。これで interval_sec 変更、追加、depends_on の組み直しが無停止で効く

3. 実行コードはサブプロセスごと差し替える

importlib.reload は最初に試した。たまに動くけどたまに壊れる。「インポート済みモジュールを参照してるコードが新旧両方のクラスを掴む」状態が一番怖い。isinstance が False になったりしてデバッグ不能。やめて実行を別プロセスに切る形に変えた。

import subprocess, json

def execute_agent(agent):
    proc = subprocess.run(
        ["python", "-m", "coo.agent_runner", agent["impl"]],
        input=json.dumps(agent),
        capture_output=True, text=True, encoding="utf-8",
        timeout=agent.get("timeout_sec", 300),
    )
    if proc.returncode != 0:
        raise RuntimeError(f"{agent['id']} failed: {proc.stderr}")
    return proc.stdout
Enter fullscreen mode Exit fullscreen mode

子プロセス側は impl を importlib.import_module して呼ぶだけ。毎回フレッシュなインタプリタが立ち上がるので agent コードを保存すれば次サイクルから新コードで動く。

起動コストは Python だけで200〜300ms。仕事自体が数秒〜数十秒なので誤差で吸収。逆にプロセス分離で失敗が daemon に波及しないメリットが大きい。

失敗体験:HUP を撃ったらサイクル途中の書き込みが半分壊れた

SIGHUP を撃った瞬間、書き込み途中の output_file が半端になった。リロード自体は問題ない。その後、新設定でスケジューラが「もう1回呼んでいい」と判断して、まだ書き終わってない output_file に2つ目の write が走った。前回の write_atomic(tmp→rename)でほぼ吸収できたけど、「同じエージェントが二重起動しない」ロックも追加した。set に id を入れて入ってる間はスキップ。10行で止まった。

教訓は1つ。無停止で替える機能を入れるときは、いま走ってるものとの競合を先に考える

まとめ

  1. SIGHUP で設定再読込 — ロックと旧設定の保持
  2. plan.json のホットリロード — mtime 監視+バリデーション、state は分離
  3. エージェント実装はサブプロセス分離importlib.reload に依存しない

これで daemon は コア以外、止めずに動かし続けられる

ローカルで完璧にしてからデプロイ、じゃなくて本番が走ってる場所に直接コードを当てるスタイル。前々回の「動かしてから考える」の続きで、動かしながら直すまで来た形だ。


次回予告

次は「観察される側」じゃなく「観察する側」の設計。daemon の挙動をどう構造化したログに残すか。

— Sai


この記事が役に立ったら:僕が自律エージェントを動かすときに実際に使っているプロンプトを2つのパックにまとめました — 自律エージェント用プロンプト100選Claude Code パワーユーザー向けプロンプト。どれも「まず動かす」発想で、ターミナルに貼って即使えます。

Top comments (0)