以前の記事「CLIの出力設計を間違えると、AIエージェントは何も読めない
」では agent-exec
の stdout/stderr 分離を、「agent-execにイベント通知が来た
」では job.finished 通知を紹介しました。AIエージェント
に長時間処理を持たせるなら、次にぶつかるのがこの UI 連携です。
今回はその続きです。テーマはもっと実務寄りで、長時間ジョブを Web UI にどう接続するか。具体例として、fovm-editorial-desk の Commit & Push ボタンのように、押してすぐ終わらない処理をどう扱うかを整理します。
言い換えると、job queue UI や long-running job polling の実装パターンの話です。単にボタンを押して API を叩くだけだと壊れやすいので、どこに真実を置くかを先に決めます。
結論だけ先に書くと、ブラウザの一時 state を真実にしないのが一番大事です。真実はサーバ側の job state に置いて、UI はそれを表示するだけにした方が壊れません。
ありがちな壊れ方
最初はこう作りがちです。
- ボタンを押す
fetch("/api/commit-push")を叩く- 成功レスポンスが返ったら UI を「成功」にする
短い処理ならこれでも動きます。でも git commit && git push、画像生成、クロール、外部同期のように10秒以上かかる処理では、すぐに破綻します。
- ページをリロードしたら実行中か分からない
- API は起動成功しただけなのに、UI が完了扱いしてしまう
- 子プロセスが即死してもボタンだけ「成功」に見える
- 同じボタンを連打して二重起動する
つまり、「押した瞬間の UI 状態」と「実際の処理状態」がズレるわけです。
設計原則: browser state を真実にしない
長時間ジョブでは、真実を agent-exec の job に寄せます。
- UI: 表示と入力受付だけを担当
- サーバ API: ジョブ作成、検索、状態取得を担当
agent-exec: 実行中・成功・失敗の事実を保持
この分離にすると、ページを開き直しても job ID から状態を復元できます。ジョブが失敗した理由も stderr_tail から追えます。
agent-exec は公式 README でも、run でジョブを開始し、status で状態を見て、tail で stdout/stderr の末尾を読む流れを基本パターンとして案内しています。UI連携でも、この3つがそのまま軸になります。
最低限必要な API は 2 本
実装をシンプルに保つなら、最初は 2 本で十分です。
- 開始 API
- 状態 API
1. 開始 API は「処理成功」を返さない
たとえば POST /api/git/commit-push の責務はこうです。
- 実行前の前提確認
- 同種ジョブの多重起動防止
agent-exec runでジョブを起動job_idを返す
ここで重要なのは、開始 API が返せるのは「job が立った」までという点です。git push の成功までは保証できません。
以下の例は、agent-exec run を使ってジョブを起動し、すぐに job_id を返す最小構成です。--snapshot-after 0 を付けると初期スナップショット待ちをせず即時返却できます。
| |
この API の成功は、あくまで「開始成功」です。処理成功ではありません。
2. 状態 API は status と tail をまとめて返す
次に GET /api/git/commit-job のような状態 API を用意します。
やることは単純です。
- 対象ジョブを見つける
agent-exec statusで state を取るagent-exec tailで stdout/stderr の末尾を取る- UI に必要な形へ整えて返す
以下の例では、まずリポジトリの working directory で agent-exec list を実行し、--tag で絞ってから、対象ジョブがあれば status と tail を返します。
| |
これで UI は、サーバが返す job state だけを見ればよくなります。
UI は optimistic に始めて、真実には追従する
ここは少しややこしいです。さっき「browser state を真実にしない」と書きましたが、だからといって開始レスポンス後の UI 更新まで遅らせると体験が悪くなります。
ボタンを押した直後は、まず optimistic に running を表示してよいです。ただし、その state を真実として保持し続けない。すぐ状態 API に接続して、以後はサーバ側 job state に追従させます。
以下のように、クリック時の即時更新と、後続のポーリングを分けると扱いやすいです。
| |
最低でも UI 状態はこの 4 つを持っておくと十分です。
idlerunningsuccessfailed
--cwd と --tag を先に決める
UI連携で地味に効くのが、job の検索軸を最初に設計しておくことです。
agent-exec list はデフォルトで現在の working directory のジョブだけを返します。さらに --tag で絞り込めます。README でも、--tag aaa の完全一致と project.build.* のような namespace prefix の両方が案内されています。
この性質を使うと、同種ジョブの復元と多重起動防止がかなり安定します。
逆に言うと、開始 API を動かすプロセスの working directory と、状態 API を引くプロセスの working directory がズレると job を見失います。 開発中に「さっき起動した job が list で出ない」となったら、まず cwd の不一致を疑った方が早いです。全ディレクトリ横断で拾いたいなら --all を使えますが、通常の UI 連携では対象ディレクトリを固定した方が安全です。
| |
タグ設計のコツは、グローバル一意を狙いすぎないことです。まず --cwd でスコープを絞り、その中で衝突しないタグを付ければ十分です。
たとえばこんな分け方が扱いやすいです。
git.commitcontent.publishimage.generateapp.fovm-editorial-desk
実運用では「どこで実行するか」が先
UI連携の記事で一番見落としやすいのがここです。agent-exec を入れれば全部解決、ではありません。
たとえば git push は、Web アプリがコンテナ内で動いていても、実際の認証はホスト側の SSH agent や Git credential helper にぶら下がっていることがあります。このとき、何も考えずコンテナ内からジョブを起動すると、job 自体は立つのに push だけ失敗します。
開始 API が成功しても、子プロセスは普通に即死します。以前の agent-exec 記事でも触れた通り、開始成功と処理成功は分けて扱うべきです。
判断順はこうです。
- この処理はコンテナ内で実行すべきか
- ホスト側の認証文脈が必要か
- 必要な PATH と資格情報はどこにあるか
この順番を飛ばすと、UI 側でいくら丁寧に state を描いても運用で壊れます。
認可を UI と job で混ぜない
もうひとつ実務で効くのが、誰がボタンを押せるかとその job が何を実行できるかを分けることです。
たとえば Commit & Push ボタンを管理画面に置くとしても、UI 側のログイン状態だけで git push の権限境界を説明した気にならない方がいいです。
- UI 側では「この操作を起動してよいユーザーか」を判定する
- サーバ側では「このジョブに渡してよい引数か」を固定する
- 実行環境側では「このプロセスが触れてよい資格情報か」を制限する
この 3 層を分けておくと、事故が起きたときの原因切り分けが速くなります。
特に避けたいのは、フロントから受け取った文字列をそのまま長時間 job に流す設計です。UI 連携で欲しいのは「任意コマンド実行」ではなく、事前に意味が決まった操作を安全に起動することです。
失敗時に stderr_tail を見せないと詰む
長時間ジョブの UI でありがちな事故は、「失敗したことは分かるが、何が失敗したか分からない」です。
たとえば PATH 差異で実行ファイルが見つからないケースです。
| |
これは agent-exec の問題ではなく、実行環境の問題です。ただ、UI が stderr_tail を返せれば、その場で原因が見えます。
逆に state=failed だけ返して詳細を隠すと、運用者はサーバに SSH してログを掘るしかなくなります。Web UI に載せるのが怖いなら、少なくとも管理画面では stderr_tail まで出した方が良いです。
polling と event をどう使い分けるか
ここまで polling 前提で書きましたが、実際には agent-exec は job.finished イベントも持っています。なので構成は2段階で考えると楽です。
- まずは polling だけで完成させる
- 必要なら event で更新を短絡する
UI の初期実装では status と tail のポーリングだけで十分です。理由は、ページ再読み込み後の復元まで含めて考えると、どうせ状態 API は必要だからです。
一方で「完了した瞬間に通知したい」「チャットや別セッションへ流したい」なら、前回記事で紹介した job.finished を併用すると綺麗です。イベントは即時性の改善には効きますが、真実の保存場所そのものにはなりません。
polling 間隔は短すぎても長すぎても困る
実装時に迷いやすいので、ここも先に基準を置いておくと楽です。
- 実行直後の数秒は 1〜2 秒間隔
- 長時間実行に入ったら 3〜5 秒間隔
- 完了後は polling を止める
理由は単純で、開始直後は state が切り替わりやすく、長時間フェーズではそこまで細かく見なくても困らないからです。
もし複数ユーザーが同じ画面を開くなら、固定 1 秒 polling をずっと続けるだけで無駄な負荷になります。だから最初は単純な polling でよいですが、間隔の切り替えだけは最初から入れておく価値があります。
自分ならこう切る
実装を始めるなら、私は次の順で作ります。
- 開始 API を作る
- 状態 API を作る
- UI に
idle/running/success/failedを入れる stderr_tailを表示する--cwd+--tagで多重起動防止を入れる- 必要になってから
job.finishedを足す
この順番なら、途中で止めても毎段階で動くものが残ります。
まとめ
長時間ジョブを UI に接続するとき、難しいのはボタンの見た目ではありません。どこに真実を置くかです。
自分の結論はかなりシンプルです。
- browser state を真実にしない
- 開始成功と処理成功を分ける
- 開始 API と状態 API を分ける
statusとtailを前提に UI を作るrunの--cwdとlistの working directory /--tagで復元と多重起動防止を安定させる- 実行場所と認証境界を先に決める
agent-exec は「長時間コマンドを JSON で安全に扱う CLI」として使っても十分便利なんですが、UI とつなぐと価値が一段上がります。ブラウザの一時 state からジョブの事実を切り離せるので、やっと運用に耐える形になります。
