Fragments of verbose memory

冗長な記憶の断片 - Web技術のメモをほぼ毎日更新

Mar 22, 2026 - 日記

agent-execで長時間ジョブをWeb UI連携する: browser stateを真実にしない実装パターン

agent-execで長時間ジョブをWeb UI連携する: browser stateを真実にしない実装パターン

以前の記事「CLIの出力設計を間違えると、AIエージェントは何も読めない 」では agent-exec の stdout/stderr 分離を、「agent-execにイベント通知が来た 」では job.finished 通知を紹介しました。AIエージェント に長時間処理を持たせるなら、次にぶつかるのがこの UI 連携です。

今回はその続きです。テーマはもっと実務寄りで、長時間ジョブを Web UI にどう接続するか。具体例として、fovm-editorial-deskCommit & Push ボタンのように、押してすぐ終わらない処理をどう扱うかを整理します。

言い換えると、job queue UIlong-running job polling の実装パターンの話です。単にボタンを押して API を叩くだけだと壊れやすいので、どこに真実を置くかを先に決めます。

結論だけ先に書くと、ブラウザの一時 state を真実にしないのが一番大事です。真実はサーバ側の job state に置いて、UI はそれを表示するだけにした方が壊れません。

ありがちな壊れ方

最初はこう作りがちです。

  1. ボタンを押す
  2. fetch("/api/commit-push") を叩く
  3. 成功レスポンスが返ったら 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 本で十分です。

  1. 開始 API
  2. 状態 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 を付けると初期スナップショット待ちをせず即時返却できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
export async function postCommitPush(req: Request) {
  await assertWorkspaceClean();
  await assertCredentialsReady();

  const active = await findActiveCommitJob();
  if (active) {
    return Response.json({
      ok: true,
      active: true,
      job: active,
    });
  }

  const result = await execJson([
    "agent-exec",
    "run",
    "--snapshot-after",
    "0",
    "--cwd",
    REPO_DIR,
    "--tag",
    "git.commit",
    "--tag",
    "app.fovm-editorial-desk",
    "--",
    "git",
    "push",
    "origin",
    "main",
  ]);

  return Response.json({
    ok: true,
    active: true,
    job: {
      job_id: result.job_id,
      state: result.state,
    },
  });
}

この 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 で絞ってから、対象ジョブがあれば statustail を返します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
export async function getCommitJob() {
  const job = await findLatestCommitJob();
  if (!job) {
    return Response.json({ active: false, job: null });
  }

  const status = await execJson(["agent-exec", "status", job.job_id]);
  const tail = await execJson([
    "agent-exec",
    "tail",
    "--tail-lines",
    "40",
    job.job_id,
  ]);

  return Response.json({
    active: status.state === "running",
    job: {
      job_id: status.job_id,
      state: status.state,
      exit_code: status.exit_code ?? null,
      created_at: status.created_at ?? null,
      started_at: status.started_at ?? null,
      finished_at: status.finished_at ?? null,
    },
    stdout_tail: tail.stdout_tail,
    stderr_tail: tail.stderr_tail,
  });
}

これで UI は、サーバが返す job state だけを見ればよくなります。

UI は optimistic に始めて、真実には追従する

ここは少しややこしいです。さっき「browser state を真実にしない」と書きましたが、だからといって開始レスポンス後の UI 更新まで遅らせると体験が悪くなります。

ボタンを押した直後は、まず optimistic に running を表示してよいです。ただし、その state を真実として保持し続けない。すぐ状態 API に接続して、以後はサーバ側 job state に追従させます。

以下のように、クリック時の即時更新と、後続のポーリングを分けると扱いやすいです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
type JobUiState = "idle" | "running" | "success" | "failed";

async function onCommitPushClick() {
  setButtonState("running");

  const res = await fetch("/api/git/commit-push", { method: "POST" });
  const data = await res.json();

  if (!data.ok) {
    setButtonState("failed");
    return;
  }

  renderJob(data.job);
  startPolling();
}

async function refreshJob() {
  const res = await fetch("/api/git/commit-job");
  const data = await res.json();

  if (!data.active && !data.job) {
    stopPolling();
    setButtonState("idle");
    return;
  }

  renderJob(data.job);
  renderTail(data.stdout_tail, data.stderr_tail);

  if (data.job.state === "running") {
    setButtonState("running");
    return;
  }

  stopPolling();
  setButtonState(data.job.exit_code === 0 ? "success" : "failed");
}

最低でも UI 状態はこの 4 つを持っておくと十分です。

  • idle
  • running
  • success
  • failed

--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 連携では対象ディレクトリを固定した方が安全です。

1
2
3
4
5
cd /srv/fovm-editorial-desk
agent-exec list \
  --state running \
  --tag git.commit \
  --tag app.fovm-editorial-desk

タグ設計のコツは、グローバル一意を狙いすぎないことです。まず --cwd でスコープを絞り、その中で衝突しないタグを付ければ十分です。

たとえばこんな分け方が扱いやすいです。

  • git.commit
  • content.publish
  • image.generate
  • app.fovm-editorial-desk

実運用では「どこで実行するか」が先

UI連携の記事で一番見落としやすいのがここです。agent-exec を入れれば全部解決、ではありません。

たとえば git push は、Web アプリがコンテナ内で動いていても、実際の認証はホスト側の SSH agent や Git credential helper にぶら下がっていることがあります。このとき、何も考えずコンテナ内からジョブを起動すると、job 自体は立つのに push だけ失敗します。

開始 API が成功しても、子プロセスは普通に即死します。以前の agent-exec 記事でも触れた通り、開始成功と処理成功は分けて扱うべきです。

判断順はこうです。

  1. この処理はコンテナ内で実行すべきか
  2. ホスト側の認証文脈が必要か
  3. 必要な PATH と資格情報はどこにあるか

この順番を飛ばすと、UI 側でいくら丁寧に state を描いても運用で壊れます。

認可を UI と job で混ぜない

もうひとつ実務で効くのが、誰がボタンを押せるかその job が何を実行できるかを分けることです。

たとえば Commit & Push ボタンを管理画面に置くとしても、UI 側のログイン状態だけで git push の権限境界を説明した気にならない方がいいです。

  • UI 側では「この操作を起動してよいユーザーか」を判定する
  • サーバ側では「このジョブに渡してよい引数か」を固定する
  • 実行環境側では「このプロセスが触れてよい資格情報か」を制限する

この 3 層を分けておくと、事故が起きたときの原因切り分けが速くなります。

特に避けたいのは、フロントから受け取った文字列をそのまま長時間 job に流す設計です。UI 連携で欲しいのは「任意コマンド実行」ではなく、事前に意味が決まった操作を安全に起動することです。

失敗時に stderr_tail を見せないと詰む

長時間ジョブの UI でありがちな事故は、「失敗したことは分かるが、何が失敗したか分からない」です。

たとえば PATH 差異で実行ファイルが見つからないケースです。

1
opencode: not found

これは agent-exec の問題ではなく、実行環境の問題です。ただ、UI が stderr_tail を返せれば、その場で原因が見えます。

逆に state=failed だけ返して詳細を隠すと、運用者はサーバに SSH してログを掘るしかなくなります。Web UI に載せるのが怖いなら、少なくとも管理画面では stderr_tail まで出した方が良いです。

polling と event をどう使い分けるか

ここまで polling 前提で書きましたが、実際には agent-execjob.finished イベントも持っています。なので構成は2段階で考えると楽です。

  • まずは polling だけで完成させる
  • 必要なら event で更新を短絡する

UI の初期実装では statustail のポーリングだけで十分です。理由は、ページ再読み込み後の復元まで含めて考えると、どうせ状態 API は必要だからです。

一方で「完了した瞬間に通知したい」「チャットや別セッションへ流したい」なら、前回記事で紹介した job.finished を併用すると綺麗です。イベントは即時性の改善には効きますが、真実の保存場所そのものにはなりません。

polling 間隔は短すぎても長すぎても困る

実装時に迷いやすいので、ここも先に基準を置いておくと楽です。

  • 実行直後の数秒は 1〜2 秒間隔
  • 長時間実行に入ったら 3〜5 秒間隔
  • 完了後は polling を止める

理由は単純で、開始直後は state が切り替わりやすく、長時間フェーズではそこまで細かく見なくても困らないからです。

もし複数ユーザーが同じ画面を開くなら、固定 1 秒 polling をずっと続けるだけで無駄な負荷になります。だから最初は単純な polling でよいですが、間隔の切り替えだけは最初から入れておく価値があります。

自分ならこう切る

実装を始めるなら、私は次の順で作ります。

  1. 開始 API を作る
  2. 状態 API を作る
  3. UI に idle/running/success/failed を入れる
  4. stderr_tail を表示する
  5. --cwd + --tag で多重起動防止を入れる
  6. 必要になってから job.finished を足す

この順番なら、途中で止めても毎段階で動くものが残ります。

まとめ

長時間ジョブを UI に接続するとき、難しいのはボタンの見た目ではありません。どこに真実を置くかです。

自分の結論はかなりシンプルです。

  • browser state を真実にしない
  • 開始成功と処理成功を分ける
  • 開始 API と状態 API を分ける
  • statustail を前提に UI を作る
  • run--cwdlist の working directory / --tag で復元と多重起動防止を安定させる
  • 実行場所と認証境界を先に決める

agent-exec は「長時間コマンドを JSON で安全に扱う CLI」として使っても十分便利なんですが、UI とつなぐと価値が一段上がります。ブラウザの一時 state からジョブの事実を切り離せるので、やっと運用に耐える形になります。

参考リンク