離脱したタスクを「見る」仕組みと「分解する」頭脳を載せた ― 結果表示ページと処理分割パイプライン、そして既にあった通知レールに1本の分岐を足すだけで待ち合わせができた話

前回、長時間タスクをバックグラウンドに逃がす「猶予つき離脱」を入れました。逃がしたタスクは完了時に Nextcloud Talk へ通知が飛び、メッセージには task_id が載ります。今回はその続きで、二つのものを載せた話です。ひとつは、その task_id を踏んで結果を見られる受け皿(結果表示ページ)。もうひとつは、その受け皿に乗る重いタスクを生む側 ―― ひとつの依頼を分解して並列で解く頭脳(処理分割パイプライン)です。

結論を先に言うと、後者の山場だと思っていた「子タスクの待ち合わせ」は、前回作った通知ループに分岐を1本足すだけで済みました。新しい待ち合わせ機構は一切作っていません。設計の段階で、前回のレールを汎用形にしておいた狙いがそのまま効いた回です。

その1:離脱した task_id を「踏める」ようにする

通知に task_id が載っても、それを OpenWebUI で探し直すのは面倒です。そこで読み取り専用の結果表示ページ GET /task/<id>/view を足しました。通知メッセージのリンクから踏むと、そのタスクの状態・モデル・担当 Worker・ドメイン・作成/更新時刻と、依頼の原文・結果が1ページで見られます。

地味ですが効くポイントが二つあります。

  • 実行中・待機中は5秒ごとに自動更新<meta http-equiv="refresh"> を、まだ結果が出ていないときだけ差し込みます。完了したら更新は止まります。
  • 動的値はすべて html.escape してから出す。LLM の出力には平気で <script> めいた文字列が混ざるので、ページに流し込む値はすべてエスケープしています。実際に結果へ <script> を含むタスクで、無害化されて表示されることを確認しました。

そして、これははっきり書いておくべき注意です。このページは前段認証を持ちません。task_id を知っていれば誰でも結果が見られます。LAN 内での運用が前提で、外に公開するなら Basic 認証なり SSO なり VPN なりを必ず前段に置く必要があります。利便性のために「踏めば見える」を選んだ以上、その境界は明示しておきます。

通知側は TASK_VIEW_BASE_URL という環境変数で、未設定なら従来どおりリンクを足さない(fail-safe)作りです。導線を増やしても、設定しなければ挙動は一文字も変わりません。

その2:依頼を分解して、並列で解く

本題のパイプラインです。狙いは「ひとつの大きな依頼を、独立に解けるサブタスクへ分解 → 並列実行 → 統合」。たとえば「①このコードを書いて ②この数値を調べて ③この文章を訳して」のような依頼を、3本の独立タスクに割って同時に走らせ、最後にまとめる、というものです。

分解は「直叩き」、実行は「既存のレールに乗せる」

分解役(planner)は、ローカル LLM をルーティングを通さず直接叩いて厳格な JSON を出させます。なぜ直叩きかというと、planner の呼び出しがルーティングの統計(どのモデルがどのドメインで速いか)に混ざると、計測が汚れるからです。これは品質採点や RAG 合成で既に使っている分離パターンと同じ考え方です。

一方、分解後の子タスクは既存のルーティング /route にそのまま乗せます。planner で直叩きしない。こうすると、子は既存基盤の恩恵 ―― 負荷分散、自己修復(死んだ Worker の再投入)、能力ベースの振り分け、Wake-on-LAN、品質スコアリング ―― をそっくり享受できます。新しく作るのは「分解する頭」と「待ち合わせて統合する頭」だけ。実行基盤は一切作り直していません。

待ち合わせは、前回のレールに分岐を1本足すだけ

前回の通知ループを、私は意図して汎用形で書いていました。芯は「notify:pending を走査して、終了したタスクの登録アクションを channel で振り分けて発火する」だけ。前回の channel は "talk"(Talk へ通知)ひとつでした。

今回足したのは channel="pipeline_join" という分岐ひとつです。子タスクを notify:pending に登録するとき、アクションに「この子が終わったら親パイプラインに合流させてくれ」と書いておく。あとは同じ走査ループが、子の完了を拾うたびに親の残カウントを1つ減らし、0になったら統合役(integrator)を起動して親を完了にする ―― という流れが、新しいループを足さずに成立しました。子が1本でもエラーになったら、その時点で親をエラー確定し、既に終わっている子の結果は破棄せず残します。

ユーザーへの完了通知も新経路は増やしていません。親は普通のタスク行なので、前回の "talk" 通知を親の task_id に対して使うだけです。

先回りで潰した三つの地雷

設計段階と実装で、表に出る前に潰したものが三つあります。

fast-child レース。 子を登録した直後、まだ親の状態(残カウント)を作り終える前に、その子が爆速で終わってしまうと、合流処理が「親の状態が無い」と取りこぼします。なので子の登録は、親の状態を確定させた後に行うよう順序を固定しました。

掃除屋(reaper)の誤爆。 このクラスタには、長時間 running のまま固まったタスクを再投入する自己修復機構があります。ところがパイプラインの親は Worker が処理しない「Coordinator 主導」のタスクなので、放っておくと掃除屋に「ゾンビだ」と誤判定されかねません。親に coordinator-pipeline という番人用の worker 名を付け、掃除屋の走査から1行で除外しました。代わりに、親が永久に running で固まらないよう、全体タイムアウトの見張りを通知ループの中に持たせています。

planner が壊れても本筋を壊さない。 小〜中サイズのモデルは、JSON を頼んでも前置きを付けたり崩れた JSON を返したりします。なので「パース失敗・サブタスクが2本未満・多すぎ・1本でも壊れている」なら、分解せず元の依頼を1本のタスクとして流す(単一縮退)ようにしました。これが効くことは、実機で図らずも証明されました(後述)。すべて既定OFF・fail-open で、スイッチを切れば挙動は完全に元どおりです。

実機で、一段ずつ確かめた

配備して、段階を分けて確認しました。ここで二つ、計測の話があります。

縮退パスが、狙わずして実証された。 最初に投げたとき、planner の向き先(gemma3:12b を載せたマシン)に到達できませんでした。すると設計どおり「分解せず単一タスクに縮退 → 子1本 → 合流で残カウント0 → 統合(単一なので素通し)→ 親 完了」と動き、親はちゃんと結果を返しました。planner も integrator も一度も使わずに、待ち合わせのレールが端から端まで通ったことの証明になりました。失敗が、いちばん見たかった経路を見せてくれたわけです。

本筋(3分割)も通った ―― ただし私は途中で早合点した。 到達先を直して投げ直すと、planner が依頼を3本に分解し、子が 2台のマシンに、別々のモデル・別々のドメイン(コード/一般/日本語)で並列に 散って実行され、残カウントが3→0と減り、統合役がまとめて親が完了しました。分解・ドメイン分類・能力ベースの振り分けが、子ごとに独立に効いている証拠です。

正直に書くと、この回、私は途中のログ窓が短くて「受付」しか見えなかった段階で「また縮退したな」と早合点しました。あとでログを取り直したら、しっかり children=3 ―― 3分割が通っていました。観測が出そろう前に結論を出してはいけない。 このクラスタを作りながら何度も自分に言い聞かせている「計測してから信じる」を、今回は自分の判断そのものに対して適用し損ねた、という記録です。

計測が暴いた、もうひとつの事実

到達できなかった原因を切り分ける過程で、構成上の事実がはっきりしました。分解に使いたい大きめのモデル(gemma3:12b)を載せているマシンは二台。片方は Ollama がローカル限定でバインドされていて LAN に出ていない。もう片方は ―― 母艦の OS は起動していても、その上の Linux 環境が自動では起動しない。つまりパイプラインの頭脳は、その Linux 環境が起きていることが前提で、寝ていれば全依頼が静かに単一縮退する(壊れはしないが、分解されない)。

これは責めるべきバグではなく、運用の前提条件です。そして「壊れない」のは縮退を fail-open にしておいたから。地雷を踏んでも事故にならない設計が、ここで効きました。前段の見張りを必ず立ててから本番、という運用に落とし込みます。

まとめ

触ったのは Coordinator の API 1ファイルだけ。Worker は無改修、データベースのスキーマ変更もなし。スイッチを切れば挙動は完全に元どおりです。新しい待ち合わせ機構を作らず、前回のレールに分岐を1本足すだけで「分解 → 並列 → 統合」が成立したのは、ひとつ前の機能を作るときに「これは次の土台になる」と決めて汎用形にしておいたからでした。機能は積み上がるほど、ひとつ前の設計判断に利子がつく ―― それを実感した回です。

残っているのは、子をわざとエラーにして親の部分失敗を見る検証、離脱通知との連動、再起動をまたいだ復旧、そして OpenWebUI から擬似モデルとして選べるようにする導線。ひとつずつ、計測してから進めます。