Hermes Agent Wiki 非公式・日本語wiki

cron の内部構造

目次

cron のサブシステムは、決まった時刻にタスクを実行する仕組みです。単純な一回きりの遅延実行から、スキルを差し込んでプラットフォームをまたいで結果を届ける繰り返しジョブまでを受け持ちます。

主要なファイル

ファイル 役割
cron/jobs.py ジョブのモデル、保存、jobs.json への不可分な読み書き
cron/scheduler.py スケジューラのループ。実行時刻が来たジョブの検出、実行、繰り返し回数の管理
tools/cronjob_tools.py モデルから見える cronjob_manage ツールの登録とハンドラ
gateway/run.py ゲートウェイとの連携。常駐ループの中で cron を刻む
hermes_cli/cron.py CLI の hermes cron サブコマンド

スケジュールの指定方法

指定できる形式は4つです。

形式 例 動作
相対的な遅延 30m, 2h, 1d 一回きり。指定した時間が過ぎたら実行します
間隔 every 2h, every 30m 繰り返し。一定の間隔で実行します
cron 式 0 9 * * * 標準的な5フィールドの cron 記法(分、時、日、月、曜日)
ISO 形式の時刻 2025-01-15T09:00:00 一回きり。指定した時刻ちょうどに実行します

モデルから見えるのは cronjob_manage ツール1つだけで、その中で操作を切り替えます。create、list、update、pause、resume、run、remove があります。

ジョブの保存先

ジョブは ~/.hermes/cron/jobs.json に保存されます。書き込みは一時ファイルへ書いてから名前を変える方式で、途中の状態が読まれないようにしています。1件のジョブは次のような内容です。

{
  "id": "a1b2c3d4e5f6",
  "name": "Daily briefing",
  "prompt": "Summarize today's AI news and funding rounds",
  "schedule": {
    "kind": "cron",
    "expr": "0 9 * * *",
    "display": "0 9 * * *"
  },
  "skills": ["ai-funding-daily-report"],
  "deliver": "telegram:-1001234567890",
  "repeat": {
    "times": null,
    "completed": 42
  },
  "state": "scheduled",
  "enabled": true,
  "next_run_at": "2025-01-16T09:00:00Z",
  "last_run_at": "2025-01-15T09:00:00Z",
  "last_status": "ok",
  "created_at": "2025-01-01T00:00:00Z",
  "model": null,
  "provider": null,
  "script": null
}

last_status に入る値

last_status は、実行が終わったときに cron.jobs.mark_job_run が書き込み、配信が待ち行列に 入った場合は、後述の配信結果の書き戻しによって 1 回だけ書き換えられます。表示する側 (hermes cron list と doctor、cronjob_manage ツール、Web ダッシュボードのバッジ、 デスクトップの定期実行インスペクタ)はどれも値ごとに意味を明示的に対応づけます。 「利用者の手元に結果が届いた」の判定を == "ok" で済ませてはいけません。

値 意味 詳細が入るフィールド
ok エージェントの実行が成功し、宛先がある場合は配信も確認できた —
delivery_queued エージェントの実行は成功し、送信は消えずに残る配信待ち行列へ引き渡されたが、まだ結果が確定していない(途中の状態) last_delivery_queued
error エージェントの実行が失敗した last_error
delivery_failed エージェントの実行は成功したが、出力が宛先まで届かなかった last_delivery_error(last_error は null)
blocked_config 実行前の検証で弾き、無駄な実行を防いだ last_error

待ち行列に入った配信の流れと書き戻し

配信先と execution_id を持つエージェントの実行は、その場では送信しません。内容を cron/delivery_queue.py(プロファイルの cron/deliveries.db で、 execution_id をキーにしたもの)へ引き渡し、last_status = "delivery_queued" と、台帳の delivery_outcome = "queued" を記録します。ゲートウェイの定期的な後片付けの処理が、動いているアダプタを通して 待ち行列を送り出します(scheduler.drain_delivery_queue)。各行が確保されるのは多くても 1 回だけです (pending → delivering → delivered | failed | suppressed)。確保したゲートウェイが送信の途中で 止まった行は unknown として締め出され、再送されることはありません。

続いて cron/delivery_outcome.py::settle_queued_delivery が、確定した結果を、 その配信を待ち行列に入れた実行へ書き戻します。

待ち行列の行 台帳の delivery_outcome ジョブの last_status / last_delivery_error
delivered delivered ok / null
suppressed suppressed ok / null
failed failed delivery_failed / 送信時のエラー
unknown failed delivery_failed / 「送信の結果が不明で、再送もしなかった」

これは両方の側から呼ばれます。送り出し側は行の結果を確定させた直後に、実行側は自分の記録を済ませた直後に呼びます。 送り出しが、実行側の mark_job_run の書き込みより先に終わることがあるためです。 どの書き込みも execution_id で締め出されます(その ID が、ジョブの台帳上の最新の試行であり、記録された最新の完了でもある 必要があります)。そのため何度呼んでも結果は同じで、あとの実行の状態を書き換えることもありません。台帳が動くのは queued から確定した結果への 1 方向だけです。ゲートウェイがその行を送り出すまで、last_status は delivery_queued のままで、 hermes cron list には「実行は終わり、配信はまだ進行中」と表示されます。

ジョブの状態

状態 意味
scheduled 有効。次の予定時刻に実行されます
paused 停止中。再開するまで実行されません
completed 繰り返し回数を使い切ったか、一回きりのジョブが実行済み
running 実行中(一時的な状態)

古い形式との互換

以前のジョブには skills の配列ではなく skill という単一のフィールドが入っていることがあります。スケジューラは読み込み時にこれをそろえ、単一の skill を skills: [skill] に置き換えます。

スケジューラの動作

1回分の処理

スケジューラは一定間隔(既定では60秒ごと)で動きます。

tick()
  1. Acquire scheduler lock (prevents overlapping ticks)
  2. Load all jobs from jobs.json
  3. Filter to due jobs (next_run <= now AND state == "scheduled")
  4. For each due job:
     a. Set state to "running"
     b. Create fresh AIAgent session (no conversation history)
     c. Load attached skills in order (injected as user messages)
     d. Run the job prompt through the agent
     e. Deliver the response to the configured target
     f. Update run_count, compute next_run
     g. If repeat count exhausted → state = "completed"
     h. Otherwise → state = "scheduled"
  5. Write updated jobs back to jobs.json
  6. Release scheduler lock

逃した回についての取り決め(再起動のすき間)

繰り返しのジョブは1 回の予定につき多くても 1 回だけ実行され、しかもすべての予定の行方が 説明できます。予定は実行される(その scheduled_instant を持つ実行の行が 1 つできる)か、 理由を付けて飛ばしたことがログに残るかのどちらかです。予定が黙って消えることはありません。 仕組みは次のとおりで、実行時刻の走査(cron/jobs.py::_evaluate_due_job)が当てはめる順に並べています。

  1. 振り分け前の前進は仮のものです。 tick() は振り分けの*前に*、実行時刻の来た予定より先へ

next_run_at を進めます。実行の途中で落ちても、再起動のたびに同じ回が走り直さないようにするためです。 ただしそれだと、進めたのに実行の確保がまだない、というすき間ができます(インタプリタの終了処理中、 実行役が仕事を断った、SIGKILL)。そこで実行時刻の走査は、同じ保存の中で記録に pending_slot = {scheduled_at, at, by} を刻みます。claim_job_for_fire(これより後は副作用が ありうる地点)と mark_job_run がこれを消し、schedule / next_run_at / enabled / state を明示的に書き換える操作(編集、一時停止、再開、いますぐ実行)でも消えます。

  1. 戻すのは 1 回だけです。 あとの走査で pending_slot が見つかり、その持ち主がいなくなったと

確かに言える場合(このプロセスで、そのジョブが実行中の集合にない。あるいは別のプロセスで、300 秒の 実行確保の期限を過ぎているか pid が死んでいる)、scheduled_at を next_run_at に戻し、 刻印を消して WARNING をログに出します(cron/occurrences.py::unclaimed_pending_slot)。これは 失われた予定 1 つにつき多くても 1 回です。戻した時刻はそのあと、ほかの遅れた回と同じく下の ふつうの規則に当てはめられるので、N 回分がまとめて再生されることはありません。

  1. 実行済みなら二度と走りません。 completed_occurrence() は、何かを実行時刻とみなす前に、

実行台帳からちょうどその scheduled_instant を持つ completed の行を探します。再起動の前に 走った回は、実行されずに先へ進みます。failed / unknown の行は完了とみなしません。 また、finished_at(無ければ claimed_at)が、その行に刻まれた時刻より前になっている completed の行も完了とみなしません。まだ来ていない回を、実行が済ませたと証明することは できないからです。比べられる時刻を持たない行は、これまでどおり完了として数えます。 回の識別は、実行時刻が来てはじめて確保できます。claim_job_for_fire はまだ未来にある scheduled_instant を捨てるので、ティックの外での発火(ダッシュボードのトリガー、webhook、 リースの取り戻し、取りこぼしの受け止め)は、次の回を消費せず、回にひも付かないまま走ります。

  1. 猶予の内側の遅れなら、遅れて実行します。 猶予は周期の半分を

[120 s, 2 h] に収めたもの(_compute_grace_seconds)で、振り分けには last_dispatch.kind = late が刻まれます。

  1. 猶予を過ぎたら、たまった分をまとめて 1 回実行します(kind = catch_up)。ただし運用者が

cron.catch_up_missed: false を設定している場合(計画した停止)は、理由をログに残して飛ばします。 120 秒の猶予を過ぎた 1 回きりのジョブは、診断を添えて引退させ、生き返らせることはありません。

  1. 一時停止中・無効・終わった状態のジョブは動きません。 実行時刻の走査は、上のどれよりも前に

それらを外します。一時停止や再開をすると、残っている予定の刻印も消えます。ただし、*一時停止している間に* 実行時刻が来た繰り返しの回は失われません。resume_job は、保存されている next_run_at が 過去であればそれを実行時刻としてそのまま残し、いまを起点に取り直すことはしません(残したことも ログに記録します)。そのため再開後の最初のティックで、その回に規則 3〜5 が当てはまります。 遅れての実行かまとめての実行が 1 回起きるか、ログに残る形で飛ばされるかのどちらかです。 1 回きりのジョブと、未来の時刻は、再開時にいまを起点に計算し直します。

どの構成でも、同じ保存の項目が動きを決めます。単独の hermes -p X gateway run も、ホストのゲートウェイが受け持つプロファイル(_start_multiplex は それぞれのホームを _profile_cron_scope の下でティックします)も、まったく同じ記録を評価します。 ホストごとに 1 つのゲートウェイのプロセスが、*すべての*プロファイルの保存場所をティックします (gateway.multiplex_profiles が切り替えるのはアダプターで、cron ではありません)。実行ごとの管理 (進行中の権利の確保、並列ワーカーのプール、古いコードのときに譲るかどうかの判定)はプロファイルの ホームごとに分けて持つので、2 つのプロファイルに同じ名前のジョブがあってもぶつかりません。 自分のゲートウェイを動かしているプロファイルはティックのたびに飛ばされるので、2 つのプロセスが その保存場所を取り合うことはなく、配信も必ずそのプロファイル自身の動いているアダプターから出ていきます。

実行中は発火の権利を借り受けます。 発火した実行は fire_claim = {at, by} を持ち、鼓動を打つスレッドが 60 秒ごとに at を更新し続けます(借り受けの期限は 300 秒です)。鼓動の見張りが、その権利を別の誰かのものだと 読み取っても、それだけでは数えず、もう一度読み直します。取られたことが確かめられたときにだけ、進行中の実行を 取り消します。それでもなお、実行の結末はその場の判定ではなく、終わった時点の保存内容に照らして決まります。 保存側がまだその権利を認めるなら、実行の本当の結果(ok、または実際のエラー)が記録され、本当に 持ち主が変わっていたときは古い結果を捨て、新しい持ち主の記録に上書きすることはありません。 Interrupted by shutdown before terminal completion. が記録されるのは、権利を持ったままの実行が 本物の通信の取り消し(ゲートウェイの排出)で止められたときだけです。

ゲートウェイとの連携

ゲートウェイモードでは、cron の引き金(実行時刻が来たジョブを*いつ*動かすかを決める部分、 いわゆる「Axis B」)を差し替え可能な CronScheduler プロバイダから選びます。 ゲートウェイは resolve_cron_scheduler()(cron/scheduler_provider.py)を呼び、選ばれたプロバイダの start() を専用のバックグラウンドスレッドで動かします。ゲートウェイの後片付け用スレッドはそれとは別に走ります。

どのプロバイダを使うかは cron.provider という設定キーで決まります。

  • 空(既定) の場合は組み込みの InProcessCronScheduler が使われ、従来どおり

プロセス内のループが60秒ごとに scheduler.tick() を呼びます。プロバイダ機構が入る前と まったく同じ挙動です。

  • プロバイダ名を書いた場合(たとえばゼロまで縮退させる構成向けのマネージド cron プロバイダ

chronos)は、plugins/cron_providers/<name>/ または $HERMES_HOME/plugins/<name>/ から探して読み込みます。

指定したプロバイダが見つからない、読み込みに失敗する、あるいは is_available() == False を返す場合は、警告を出したうえで組み込みのものに戻します。cron から引き金が なくなることはありません。 組み込みのプロバイダは plugins/ ではなくコア側 (cron/scheduler_provider.py)にあるので、うっかり消してしまうこともありません。

「実行する」とは何をすることか(ジョブの実行と結果の配信)は変わらず、どのプロバイダでも共通です。 scheduler.run_job() と scheduler._deliver_result() が受け持ちます。 プロバイダが握るのは引き金だけで、実行そのものには手を出しません。

動いている最中にチェックアウトが更新されたティッカー(起動時のリビジョン ≠ ディスク上のリビジョン)が ティックを譲るのは、実際に引き継げるゲートウェイに対してだけです。実行時のロックを持っているのが 生きているゲートウェイで、その gateway_state.json の鼓動が新しく、記録された code_sha が ディスク上のリビジョンと一致していなければなりません。ロックを持っているプロセス自身がまだ更新前の コードで動いている場合(ゲートウェイが 1 つだけの環境で hermes update した直後によくある状況です)は 新しいゲートウェイとは見なさないので、ティッカーは誰もいない相手に毎回ティックを譲るのではなく、 そのまま実行を続けます。

CLI モードでは、cron ジョブは hermes cron のコマンドを実行したときか、CLI のセッションが動いている間だけ実行されます。

ゲートウェイがセッションの実行環境を持つ方式へ移るときの注意点(廃止された cron.bot_chat_delivery_timeout_seconds キー、古い cron/bot_chat_pending/ の記録、 切り替えのための再起動で中断された実行)は、 更新にまとめてあります。

ゼロまで縮退させるためのマネージド cron(Chronos)

ホスト型のゲートウェイでは、組み込みのティッカーの代わりに Chronos プロバイダ (cron.provider: chronos)を使えます。Chronos を使うと、待機中のゲートウェイを ゼロまで縮退させたまま cron ジョブを動かせます。60秒ごとにプロセス内でループを回す (つまりプロセスを起こしっぱなしにする)のではなく、Nous の基盤に対して ジョブごとに、実際の次回実行時刻ちょうどの一回きりの予約を1つだけ入れてもらいます。 時刻が来ると Nous が認証付きの Webhook(POST /api/cron/fire)でゲートウェイを呼び出し、 ゲートウェイは組み込みの場合と同じ run_one_job の経路でジョブを実行し、次の一回きりの予約を入れ直します。 実行と実行のあいだはプロセスを完全に止めておけます。起きるのは本当に実行するときだけで、 定期タイマーで起こされることはありません。

流れは次のとおりです(マネージドのスケジューラは Nous 側が提供し、エージェントは スケジューラの資格情報を持ちません)。

create/update a cron job
  → Chronos asks Nous to arm a one-shot at the job's next_run_at
      (authenticated with the agent's existing Nous token)
  → at fire time Nous calls the gateway: POST {callback_url}/api/cron/fire
      (authenticated with a short-lived, purpose-scoped Nous-minted JWT)
  → the gateway verifies the token, claims the job (store compare-and-set so
    multi-replica deployments fire at-most-once), runs it, and re-arms the next
    one-shot

設定はすべて秘密情報ではありません(ホスト型のエージェントでは Nous が用意の段階で設定します)。

キー 意味
cron.provider chronos にすると有効になります(空なら組み込みのティッカー)
cron.chronos.portal_url Nous のベース URL(予約の投入と、実行用トークンの発行元)
cron.chronos.callback_url 実行の呼び出しを受けるゲートウェイ自身の公開ベース URL
cron.chronos.expected_audience このエージェント向けの実行用トークンの audience
cron.chronos.nas_jwks_url 受け取った実行用トークンを検証するための鍵セット

Chronos の設定が誤っている場合や、エージェントが Nous にログインしていない場合は、 resolve_cron_scheduler() が警告を残したうえで組み込みのティッカーに戻します。 cron が引き金を失うことはありません。繰り返しのジョブは実行のたびに次の予約を入れ直し、 repeat に回数を指定したジョブは回数を使い切った時点できれいに止まります(予約が取り残されることはありません)。 エージェントと Nous のあいだのやり取りの取り決めは Chronos managed-cron contract にすべて書かれています。

まっさらなセッションで動かす

cron ジョブは毎回まっさらなエージェントセッションで動きます。

  • 前回までの会話は引き継ぎません
  • 前回までの cron の実行内容も覚えていません(MEMORY.md や USER.md といった

永続的な記憶は、ほかのエージェントの実行と同じように読み込まれるので、 長く残る好みや事実は引き継がれます。1回ごとの会話の文脈は引き継がれません)

  • プロンプトはそれだけで完結している必要があります。cron ジョブは聞き返せません
  • cronjob のツール群は無効になります(再帰の防止)

スキルを付けたジョブ

cron ジョブには skills フィールドで1つ以上のスキルを付けられます。実行時には次のように進みます。

  1. 指定した順にスキルを読み込みます
  2. 各スキルの SKILL.md の内容が文脈として差し込まれます
  3. ジョブのプロンプトが、やるべきことの指示として最後に足されます
  4. エージェントはスキルの文脈とプロンプトをまとめて処理します

これにより、手順の全文を cron のプロンプトに貼り付けなくても、作り込んで動作を確かめた手順をそのまま使い回せます。たとえば次のようになります。

Create a daily funding report → attach "ai-funding-daily-report" skill

スクリプトを付けたジョブ

ジョブには script フィールドで Python のスクリプトを付けることもできます。スクリプトはエージェントの各ターンの*前*に走り、その標準出力がプロンプトへ文脈として差し込まれます。データの収集や変化の検出といった使い方ができます。

# ~/.hermes/scripts/check_competitors.py

# Fetch competitor release notes, diff against last run
# Print summary to stdout — agent analyzes and reports

スクリプトの制限時間は既定で3600秒(1時間)です。_get_script_timeout() は次の3層をたどって値を決めます。

  1. モジュールレベルの上書き — _SCRIPT_TIMEOUT(テストや差し替え用)。既定値と違うときだけ使われます。
  2. 環境変数 — HERMES_CRON_SCRIPT_TIMEOUT
  3. 設定 — config.yaml の cron.script_timeout_seconds(load_config() 経由で読み込みます)
  4. 既定値 — 3600秒(1時間)

この制限時間がかかるのは実行前のスクリプトだけで、エージェント側にはかかりません。スキルや LLM で動くジョブは、*無操作*の時間を基準にした別の制限(HERMES_CRON_TIMEOUT、既定は無操作600秒、0 で無制限)で動きます。ツールを呼び続けたりトークンを出し続けたりしているかぎり何時間でも動き、何も起きない時間が設定分だけ続いたときに初めて打ち切られます。Python の単調時計がスリープ中の時間を含まないプラットフォーム(macOS と Linux)では、ホストがスリープしていた時間はこの無操作時間に数えません。Windows での動作は変わりません。スクリプトは常駐のスレッドプールに投げられ、1回分の処理のロックを握ったままにはしないので、長く走るスクリプトがほかのジョブの実行を止めることはありません。

制限時間を超えたとき、あるいは所有権が取り消されたときは、cron.scheduler_script が共通の agent.deadline.kill_process_tree による強制終了の経路を使います。POSIX ではいったん動きを止めてから、 生きているツリーを調べ直したうえで、子孫とその親へシグナルを送ります。別のセッションにいて出力パイプを 受け継いでいない子も対象です。これで、調べたあとに新しく分岐したプロセスを取りこぼす問題がなくなります。 停止を待つ時間には上限があります。調査や権限で失敗した場合は、これまでどおりグループ単位の後始末を最善努力で 行うだけで、サンドボックスのような保証にはなりません。後始末で止めた相手は、終了に失敗した場合には元に戻します。 もともと止まっていた相手は、その状態のままです。明示的に穏やかなシグナルを送るときは、相手を止めません。 Windows では引き続き taskkill /F /T を使います。

プロバイダの切り替えによる復帰

run_job() は、利用者が設定した予備のプロバイダと資格情報のプールを AIAgent のインスタンスへ渡します。

  • 予備のプロバイダ — config.yaml から fallback_providers(リスト)または fallback_model(旧来の辞書)を読み、ゲートウェイの _load_fallback_model() と同じやり方に合わせます。fallback_model= として AIAgent.__init__ に渡され、どちらの形式も予備の連なりへとそろえられます。固定していないジョブだけが対象です。 ジョブ自身が provider、model、base_url のどれかを持っている場合、_job_fallback_chain() は予備の連なりを返しません。同じ答えが、_resolve_job_runtime() での認証情報の解決の走査、実行前のキーの確認、実行中に予備へ順に切り替える処理にも使われるので、固定されたジョブが全体用の連なりの項目に落ちることはありません(#100437)。この処理は、固定された委任先の子と同じ hermes_cli.fallback_config.scoped_fallback_chain() を共有しています。
  • 資格情報のプール — 実行時に決まったプロバイダ名を使い、agent.credential_pool の load_pool(provider) で読み込みます。渡すのはプールに資格情報があるとき(pool.has_credentials())だけです。429 や利用制限のエラーが出たときに、同じプロバイダの別の鍵へ切り替えられます。

これはゲートウェイと同じ振る舞いです。これがないと、cron のエージェントは利用制限にぶつかった時点で、立て直しを試みないまま失敗してしまいます。

結果の届け方

cron ジョブの結果は、対応しているどのプラットフォームにも届けられます。

プラットフォーム名だけを書くと(slack、telegram など)、そのプラットフォームで設定してあるホームチャンネルへ届きます。特定の宛先を指すときは、コロンに続けて宛先を書きます(platform:<target>)。宛先が解決されるのはジョブを作ったときではなく実行するときなので、まだつないでいないプラットフォームの宛先を先に書いておき、つながった時点から届き始める、という使い方もできます。

多くのプラットフォームでは、3つ目の区切りとしてスレッドやトピックも指定できます(platform:<chat_id>:<thread_id>)。

宛先 書き方 例
発生元のチャット origin ジョブを作ったチャットへ届けます
ローカルのファイル local ~/.hermes/cron/output/ に保存します
Telegram telegram, telegram:<chat_id>, telegram:<chat_id>:<thread_id>, telegram:@username telegram:-1001234567890:17585
Discord discord, discord:#channel, discord:<channel_id>, discord:<channel_id>:<thread_id> discord:#engineering
Slack slack, slack:#channel, slack:<channel_id>, slack:<channel_id>:<thread_ts> slack:#engineering
Matrix matrix, matrix:<!room_id:server>, matrix:<@user:server> matrix:!abc123:example.org
Feishu feishu, feishu:<chat_id>, feishu:<chat_id>:<thread_id> feishu:oc_abc123def
WhatsApp whatsapp, whatsapp:<jid>, whatsapp:+<E.164> whatsapp:123456@g.us
Signal signal, signal:group:<id>, signal:+<E.164> signal:group:aBcD==
SMS sms, sms:+<E.164> sms:+<E.164 number>
メール email, email:<address> email:alerts@example.com
Weixin weixin, weixin:<wxid> weixin:wxid_abc123
Mattermost mattermost または mattermost:<channel_id> 名前だけなら Mattermost のホームへ届きます
Home Assistant(プラグイン) homeassistant または homeassistant:<notify target> 名前だけなら HASS_HOME_CHANNEL へ届きます。homeassistant のカタログプラグインが必要です
DingTalk dingtalk または dingtalk:<chat_id> 名前だけなら DingTalk へ届きます
WeCom wecom または wecom:<chat_id> 名前だけなら WeCom へ届きます
BlueBubbles bluebubbles または bluebubbles:<chat_guid> 名前だけなら BlueBubbles 経由で iMessage へ届きます
QQ Bot qqbot または qqbot:<chat_id> 名前だけなら公式 API v2 経由で QQ(Tencent)へ届きます
Bot Chat bot-chat または bot-chat:<profile> 手元のプロファイルの正規の Bot Chat へ流し込みます(ボットが応答します)

前半のプラットフォームには、名前付きチャンネル(#channel)、トピックやスレッド、ルームや利用者の ID、グループ ID、電話番号といった、検証つきの書き方が用意されています。残りのプラットフォームは汎用の platform:<chat_id> の形を受け付けます(コロンの後ろの値はそのまま宛先の ID として使われます)。プラットフォーム名だけを書いた場合は、いつでもホームチャンネルへ届きます。

名前付きチャンネル(slack:#engineering、discord:#engineering、あるいは slack:engineering のような読みやすい名前)は、ゲートウェイがつながっているアダプタから作るチャンネル一覧に照らして解決されます。したがって名前で指すには、ゲートウェイがそのチャンネルを見つけている必要があります。ID をそのまま書く形(slack:C0123ABCD45)はいつでも使えます。

Telegram のトピックには telegram:<chat_id>:<thread_id> を使います(例: telegram:-1001234567890:17585)。Slack のスレッドの場合、3つ目の区切りは親メッセージの thread_ts です(例: slack:C0123ABCD45:1700000000.000100)。つまり既存のメッセージにぶら下げて返すときにだけ使えます。

Bot Chat(bot-chat、bot-chat:<profile>)はゲートウェイのアダプタではなく、その端末の中だけで完結する疑似プラットフォームです。メールボックスを備えた正規の常駐の持ち主がいれば、手が空いていても塞がっていても、その相手がすぐに確実な受け取りとして受理し、届いたターンを実行するのはその持ち主だけです。scheduler_delivery._deliver_to_bot_chat は宛先を get_profile_dir、またはジョブが今使っている get_hermes_home から割り出し、送り元のホーム・ジョブの ID・消えずに残る execution_id・宛先のホームから受領票の ID を組み立て、持ち主を探す前にその受領票を確かめます。まだ Bot Chat がないプロファイルでは、最初の配信のときに管理役がそれを作ります(廃止された CLI 経路の --create-if-missing と同じ意味です)。手元の CLI へ落とす経路はありません。管理役が動いていなければ、その実行は delivery_failed として記録され、理由が last_delivery_error に入り、出力は hermes cron runs に残ります。自動で配信し直すことはなく、スケジューラがそのプロファイルの保存先に書き込む 2 人目になることもありません。届くのは本物の受信ターンであって、会話の写しではありません。受理されて確保された受領票は、その ID を last_delivery_queued に記録します。配信の集約は、受理の知らせを本当のエラーから除いたうえで、実行結果に delivery_outcome=queued を記録します。成功したジョブは last_status=delivery_queued になります。宛先が混ざっている場合、本当のエラーがあればそちらが優先されて失敗として扱われ、受理済みの受領票の情報は残ります。最後まで終わったかどうかを決める正は、宛先のプロファイル側に残る受領票です。受理はその時点で受け付けられたという記録であって、届いたことの証拠ではありません。Bot Chat への送信がゲートウェイの配信待ち行列を通った場合(エージェントの実行)は、送り出し側の送信が返った時点で、前述の書き戻しが cron の実行結果を確定させます。このとき ok/delivered は、宛先の管理役がそのターンを受け付けたことを意味し、last_delivery_queued には、Bot Chat のターンがまだ動いているかもしれない受領票の ID が残ります。その場で行われた Bot Chat への配信(no_agent のジョブや、宛先が Bot Chat だけの外部ワーカー)は待ち行列を通らないので、その実行は last_status=delivery_queued / delivery_outcome=queued のままです。あとで受領票が完了しても、それを cron の履歴へ書き戻すものはありません。bot-chat の宛先は all という一括指定の対象から外れ、配信前の資格情報の事前確認からも外れます。宛先が bot-chat だけの外部ワーカーはゲートウェイの配信待ち行列を通らず、ほかの宛先が混ざる場合はこれまでどおりゲートウェイへ引き渡します。

結果に添える定型文

既定(cron.wrap_response: true)では、cron の配信に次のものが添えられます。

  • cron ジョブの名前とやったことを示す見出し
  • 届けたメッセージをエージェント自身は会話として見られない、という断り書き

cron の応答の先頭に [SILENT] を付けると、配信そのものを止められます。ファイルへ書くだけ、あるいは何か別の処理をするだけのジョブに向いています。

セッションの切り分け

cron の配信は、ゲートウェイのセッションの会話履歴には写されません。cron ジョブ自身のセッションの中にだけ存在します。こうすることで、届け先のチャットの会話で発言の順番が崩れるのを防いでいます。

再帰の防止

cron から実行されたセッションでは cronjob のツール群が無効になります。これにより次のことを防いでいます。

  • 予定されたジョブが新しい cron ジョブを作ってしまうこと
  • 再帰的に予定が増えてトークンの消費が跳ね上がること
  • ジョブの中から、そのジョブ自身の予定をうっかり書き換えてしまうこと

ロック

スケジューラはプロセスをまたぐファイルロック(Unix では fcntl.flock、Windows では msvcrt.locking)を使い、処理が重なって同じジョブの束が二重に実行されるのを防ぎます。ゲートウェイのプロセス内ティッカーと、単独で動かした hermes cron や手動の tick() のあいだでも同じです。ロックを取れなかった場合、tick() はすぐに 0 を返します。

古いコードのときにティックを譲る

ティックのロックを取る前に、動いている最中にチェックアウトが更新されたゲートウェイ(起動時のリビジョン ≠ ディスク上のリビジョン)は、別のプロセスがゲートウェイの実行時ロックを持っていればティックを譲ります。より新しいゲートウェイのティッカーが代わりに実行し、古いほうは sys.modules が混ざった状態でそれと競ってはいけないからです。譲ったことは例外(CronTickYielded)として送出され、ティッカーの最後のエラーとして保存されます。そのため、生存確認の鼓動が更新され続けていても、hermes cron status は両方のリビジョンと再起動のコマンドを添えて "Gateway is running STALE code — its cron ticker yields every tick and fires NOTHING"(ゲートウェイが古いコードで動いていて、cron のティッカーが毎回ティックを譲り、何も実行していない)と報告します。この流れを閉じるのが hermes update です。更新後に各ゲートウェイの版を突き合わせた結果、古いと確かめられたゲートウェイは、動かしたままにされるのではなく、処理を片付けてから再起動する request_restart の経路(SIGUSR1)へ渡されます。監視下のゲートウェイは新しいコードで起動し直し、素の gateway run は停止されて「Restart manually」の一覧に載ります。

CLI からの操作

hermes cron の CLI からジョブを直接扱えます。

hermes cron list                    # Show all jobs
hermes cron create                  # Interactive job creation (alias: add)
hermes cron edit <job_id>           # Edit job configuration
hermes cron pause <job_id>          # Pause a running job
hermes cron resume <job_id>         # Resume a paused job
hermes cron run <job_id>            # Trigger immediate execution
hermes cron remove <job_id>         # Delete a job