「毎週月曜の深夜1時に」と予約したら、昼の10時に走るところだった ― AIにサーバ保守を予約させる機能と、planner を回すのは登録時の一度だけにした設計

サーバ保守を「毎週月曜の深夜1時に」「明日の朝9時に一度だけ」と日本語で予約できるようにした。planner を回すのは登録時の一度だけにして発火時はLLMを呼ばない設計と、配備先が UTC だったせいで指定時刻が9時間ずれていた話。

自宅の分散推論基盤に、サーバ保守を「毎週月曜の深夜1時に」「明日の朝9時に一度だけ」といった予約で走らせる機能を入れた。チャットに日本語で書くだけで登録できる。

作り終えて実機に載せ、試しに一件登録してみたら、応答にこう書いてあった。"run_at":"2026-08-05T09:00:00+00:00"。末尾が +00:00 だった。私が「朝9時」と言ったつもりの時刻は、日本時間の夕方6時を指していた。

1. 「もう作ってあるはずだ」から始まった

このセッションは、実装ではなく認識合わせから始まった。前回の引き継ぎに「定期実行は途中まで実装済みのはずだが、確認した限り見当たらない」と書いてあったからだ。

調べると、保守まわりには確かにタイマーが二本動いていた。coordinator-maintenance-watchdog.timer(60秒ごとに executor の無音停止を監視)と coordinator-mount-guard.timer(外付けディスクの read-only 転落を監視)である。どちらも名前に「maintenance」を含み、どちらも定期的に動く。

だが、この二本が見張っているのは実行系そのものの生死であって、保守タスクを起動する仕組みではない。「何を見張るか」と「何を走らせるか」は別のレイヤだ。私はこの二つを記憶の中で同じ棚に入れていた。

本人に確認したところ、やはり watchdog のタイマーを「定期実行機能」だと覚えていた。つまり実装はゼロからだった。ここを確かめずに「途中から」書き始めていたら、既存の監視タイマーに保守の起動を足す、という筋の悪い設計に流れていたと思う。

2. LLMを呼ぶのは、登録の一度だけ

この基盤の保守機能は、日本語の指示を LLM(planner)がカタログ準拠の実行計画に変換し、承認判定を経て各ノードの executor が実行する、という流れで動いている。カタログにない操作は原理的に実行できない。

では定期実行では、planner をいつ呼ぶか。素直に考えると二択ある。

  • 発火のたびに呼ぶ:自然文を保存しておき、毎週その文をもう一度 planner にかける
  • 登録時に一度だけ呼ぶ:登録した瞬間に計画を確定させ、以後はそれをそのまま流す

前者はカタログの変更に自動追従できる。だが深夜3時に Ollama が落ちていたら、その週の保守は何も言わずに実行ゼロになる。しかも毎回わずかに違う計画が出うる。

後者を選んだ。登録時に planner が確定させた計画を PostgreSQL に保存し、発火時はそれを読んで流すだけにする。発火経路に LLM が存在しないので、推論が不調でも沈黙スキップが起きない。この基盤で以前から使っている plan-once / execute-fixed(計画は一度、実行は機械的に)という方針の、字義どおりの適用である。

副産物として、登録した瞬間に「何が実行されるか」が確定して画面に出る。人がその場で目視できる。意図と違えば消して登録し直せばいい。実行内容を後から編集する機能をあえて作らなかったのもここが理由で、変更のたびに確認の目を通させたかった。

時刻の解釈も planner には渡していない。この基盤の planner は、入力を「本人の自然文」と「人が書いた静的カタログ」の二つに構造的に固定してある。そこに時刻という第三の出力を足すと、せっかく塞いだ入口をこじ開けることになる。だから「毎週月曜」「深夜1時」「明日」を読む小さな決定論的パーサを別に書いた。正規表現で読めなければ理由を返して拒否する。「毎日ログを見て」のような時刻のない指示が、暗黙に0時へ丸められることはない。

3. 二重に走るより、一度飛ばすほうが軽い

60秒ごとに起きるスケジューラは、発火すべき行を見つけると二つの操作をする。「発火済みの印をつける」と「キューへ投入する」だ。この順序で挙動が変わる。

投入を先にすると、投入直後にプロセスが落ちた場合、印がつかないまま次の周期を迎える。同じ予約がもう一度発火する。対象が apt upgrade だったら、パッケージ更新が二重に走る。

印を先にすると、印をつけた直後に落ちた場合、その回の実行が飛ぶ。

印を先にした。診断コマンドが一回抜けるのと、更新が二度走るのとでは、後者のほうが明確に厄介だからだ。どちらの順序を選んでも事故は起こりうる。選べるのは「どちらの事故なら受け入れるか」だけである。

同じ発想で、取りこぼしの拾い方にも上限を置いた。機械が三日止まって復帰したとき、その間に過ぎた予定を全部撃つと 72 回ぶんの apt upgrade が飛ぶ。だから遡って拾うのは既定で1時間までとし、それを超えて過ぎた単発予約は発火させずに「時期を逃した」として記録する。走らせないが、走らなかった事実は残す。黙って消さない。

4. 「毎週深夜1時」が、昼の10時になっていた

ここまでを書き、手元の Windows で70件のテストを通し、実機へ配備した。そして冒頭の +00:00 である。

timedatectl を叩いて理由がわかった。基盤の中心にいる ai-core は Etc/UTC で動いていた。

初版のコードは、時刻の解釈にホストのローカル時刻を使っていた。ホストが日本時間なら正しく動く。手元の開発機は日本時間の Windows なので、テストは全部通っていた。だが配備先は UTC だった。「毎週月曜の深夜1時に」と登録した保守は、UTC の 1時 = 日本時間の朝10時に走る。深夜に静かに済ませるつもりの更新が、昼間に動き出す。

気づけたのは、登録した直後の応答に run_at がそのまま出るようにしていたからだ。「何がいつ走るか」を人に見せるために付けた表示が、そのまま自分の設計ミスを映した。

直し方としては、サーバのタイムゾーンを日本時間に変更する手もあった。だが採らなかった。ホストの設定に依存する構造そのものが問題で、TZ を変えても「次にどこかへ移したとき、また同じことが起きる」からだ。

解釈の基準を環境変数で明示し(既定は Asia/Tokyo)、ホストの時刻設定を一切見ないようにした。「午前10時」は本人の生活時間であって、サーバのロケールではない。

回帰テストを7件足した。中でも効くのは、「UTC の月曜1時では一致しないこと」を確かめる一件だ。修正前のコードはここで一致してしまう。ズレの本体を直接押さえるテストが要る。テストは 63 件から 70 件になった。

日本時間の環境だけで検証していたら、このバグは永久に見つからなかった。時刻を扱う機能では、配備先のタイムゾーンを確認するのは手順に組み込むべき項目だと思う。

5. 配備で開いた、もう二つの穴

配備中、ローカルでは絶対に出なかった問題があと二つ出た。

一つは、スケジュール一覧を叩いたら即座に 500 が返ったこと。新しいテーブルが存在しなかった。既存の二つのテーブルは別のセットアップスクリプトが作っており、その慣習に無意識に乗っていた。だが今回の機能は「チャットから登録する」のが最初の操作になる。テーブルを作るスクリプトを誰かが走らせ忘れた瞬間、利用者から見た機能はただ壊れている。初回アクセス時に冪等な DDL を一度だけ流して自己修復させるようにした。

もう一つは、私自身の事故である。スケジューラに DB 接続情報を渡すため、秘密ファイルを作る作業をしていた。その際にクォートの形を間違え、シェルがその行をコマンドとして解釈しようとして失敗し、エラーメッセージにパスワードがそのまま載って出力された

この基盤では以前にも、検証スクリプトが .env を読み込んでパスワードを表示してしまったことがある。そのときの教訓は「秘密を含むファイルを読まない」だった。今回は読んでいない。秘密を含む文字列をシェルに解釈させ、その失敗経路から漏れた。塞いだつもりの穴の、隣に空いていた穴だった。

以後の操作はすべて、出力をマスクに通し、中身を表示せず真偽判定だけで検証するようにした。教訓を更新するなら「読まない」では足りず、秘密を扱うコマンドは、失敗したときに何が表示されるかまで設計するになる。

6. アクション名をそのまま書くと、通らない

機能が動いたあと、パッケージ更新を予約したくなって指示の書き方を試した。ここで直感に反する結果が出た。

毎週日曜午前3時にmarsのapt.updateとapt.upgradeを実行して   → 拒否
毎週日曜午前3時にmarsのapt updateとapt upgradeを実行して    → 拒否
毎週日曜午前3時にmarsのパッケージを更新して                  → apt.update + apt.upgrade(順序も正しい)
毎週日曜午前3時にmarsをapt updateしてapt upgradeして         → 同上
毎月1日午前4時にmarsのOSパッケージを最新にして                → 同上

アクション名を正確に書いたほうが落ちる。「〜を実行して」という言い回しが対応付けを妨げているらしく、同じ語でも「apt update して」と動詞化すれば通る。

これは planner が LLM である以上、完全には消せない性質だ。だから消そうとするのではなく、登録時に確定した計画を必ず画面に出すことと、登録直後にその場で試せるテスト実行を用意することで受け止めている。テスト実行は予約の状態を進めない設計にした。確認した直後に本来の定期実行が飛んでしまっては、確認したことが害になる。

おわりに ― その時計は、誰の時計か

最後に、未点火の状態で数周期ログを眺めてから点火した。点火後、人が何も触らないまま予約が発火し、対象ノードがコマンドを実行し、結果が回収され、単発の予約は自分で無効になった。次の周期では対象から外れていた。

今回いちばん効いたのは、テストでも設計でもなく、登録の応答に「いつ・何が」をそのまま出しておいたことだった。9時間のズレは、そこに +00:00 と書いてあったから見つかった。表示していなければ、月曜の昼にサーバが動き出すまで誰も気づかない。しかも「動いている」ので、故障としても現れない。

サーバは自分の時計で正しく動いていた。UTC の1時に、指示どおり起きるつもりだった。間違っていたのは時計ではなく、「1時」と書いた人間がどの時計を見ていたかを、コードが確かめていなかったことだ。

自動化とは、人の意図を機械の言葉に翻訳して置いていく作業だと思う。翻訳した瞬間は、たいてい合っている。ずれるのは、置いた先の前提が自分の手元と違っていたときだ。