長時間タスクをバックグラウンドに逃がしたら、引き継ぎ資料が実機より古かった ― 問い合わせの非同期化と、消しかけたエンドポイントの話

家庭内の分散AI推論基盤「Coordinator」を作り続けている連載の第31回です。今回は「長時間タスクを画面の前で待たずに、終わったら通知してもらう」という、ずっと先送りにしていた非同期化に手を付けました。機能そのものは素直に終わったのですが、その過程で引き継ぎ資料が実機より古く、稼働中のエンドポイントを危うく消しかけたこと、そして「これを流用すればいい」と思っていた前提が実機を見たら存在しなかったことの方が、書き残す価値のある話になりました。

何が問題だったのか ― 同期ポーリングという最後の一本

このシステムのバックエンドは、実はとっくに非同期です。OpenWebUIから投げた質問は Coordinator の /route でRedisキューに積まれ、即座に task_id が返ります。Workerが処理を終えるとPostgreSQLに書き戻し、GET /task/<id> でそれを読む。Workerの完了を待っている箇所はどこにもありません。

唯一の例外が、OpenWebUI側のPipe Functionでした。質問の答えを画面に出すために、ここだけが完了をポーリングして待っています。

for attempt in range(POLL_MAX_RETRIES):   # 150回
    await asyncio.sleep(POLL_INTERVAL)    # 2秒
    task = GET /task/<id>
    if task.status == "done": ...

150回 × 2秒 = 最大5分。チャットのターンを5分間開きっぱなしにして待つ設計です。帰結はこうなります。タブを閉じるとタスクは裏で完走してPGに残るのに、結果が表に出ない。5分を超えると諦める。長い処理を投げて放置し、後でゆっくり受け取る、ということができない。

今これが効いている場面は少ないのですが、この先に控えている「依頼を planner→coder→reviewer のように多段で処理する」構想を考えると、長時間タスクは確実に増えます。土台として、長いタスクが破綻せず受け取れるレールを先に敷いておく。それが今回の目的でした。

設計 ― 全部を非同期にはしない

真っ先に決めたのは、「全タスクを即離脱(fire-and-forget)にはしない」ということです。「質問したら同じ画面で答えが返る」という日常の使い勝手を、数秒で終わる大半のタスクから奪うのは本末転倒です。やりたいのは、長時間タスクが来たときだけ受け皿が開く追加であって、現行UXの置き換えではありません。

そこで「猶予つき離脱」にしました。3つの軸で組み立てています。

  • 軸1:pipe側の待ち方 ― 新しいValve DETACH_ENABLED(既定OFF)と DETACH_AFTER_SEC(既定60秒)。猶予内に終わったタスクは従来どおりその場で回答。超過分だけバックグラウンドへ離脱する。
  • 軸2:完了を拾う主体 ― 離脱した瞬間、pipeはCoordinatorの薄い新エンドポイント POST /task/<id>/notify を叩くだけ。Coordinator内のバックグラウンドasyncioループが「完了待ち集合」を走査し、doneになったら通知を発火する。
  • 軸3:通知の出口 ― Nextcloud Talkへ「✅完了」を投げる。

地味ですが重要なのが離脱クロックの起点です。「離脱までの60秒」は、タスクが running になった瞬間から数えます。Wake-on-LANでmoonを起こしている待ち時間や、キューで順番待ちしている時間(pending)は数えません。これをしないと、起動の遅さやキューの混雑で「まだ1秒も計算していないのに離脱」が起きてしまう。実際に処理が走り始めてから長いものだけを逃がす、という意図です。

もう一つ、pipeにはRedisの認証情報を持たせません。pipeが知っているのは COORDINATOR_URL へのHTTPだけ。Redisへの SADD はCoordinator側でやる。これは以前 /ingest_memory を作ったときと同じ「薄い入口」の踏襲で、攻撃面を増やさないための分離です。

そして通知ループは、あえて汎用形で書きました。「完了待ち集合を走査し、終わったタスクの登録済みアクションを発火する」という形です。今回のアクションは「Talkへ投稿」だけですが、次の段階(依頼の多段処理)では「待ち合わせていた子タスクが全部終わったら統合タスクを積む」を別アクションとして足すだけで、同じループがオーケストレーションの待ち合わせ機構になる。土台は土台らしく作っておきます。

実装前に足をすくわれた ― 引き継ぎ資料が実機より古い

この連載では、各セッションを「引き継ぎ資料」という長大なMarkdownで繋いでいます。全ソースコードを末尾の付録に埋め込んであり、毎回それを正本として作業を始めます。今回も付録の coordinator_api.py を編集ベースにしました。

編集して「できました」と出したところで、本人(私)が違和感を覚えて実機の現物を貼って照合しました。これが正解でした。

付録のソースは v6.5.0 のスナップショット(数日前の状態)で、実機の v6.6.0 より古かったのです。差分の核心は、実機に存在する POST /ingest_memory(会話メモリのライブ給餌エンドポイント。前回クローズしたばかりの機能)が、付録から丸ごと抜け落ちていたこと。

もし付録ベースの版をそのまま配備していたら、稼働中のエンドポイントを削除して、前回完成させた機能を黙って壊していた。新機能を足したつもりが、古い土台に足したせいで別の機能を消す ― 一番たちの悪いパターンです。

対処はシンプルで、実機の貼り付けを正本に作り直す。そこへ通知機能を載せ直しました。ついでに、もう片方の coordinator_pipe.py は付録と実機を diff にかけて完全一致(差分ゼロ)を確認 ― こちらは古くなかったので、付録ベースの編集をそのまま使えると判断できました。

実は前回も似たことが起きていて、そのときは逆向き ―「資料が未着手と書いている機能が、実機ではもう完成していた」というケースでした。今回はその裏返しで「資料に無い機能が、実機では動いていた」。引き継ぎ資料のズレは、改善を黙って巻き戻す方向にも、完成済みを取りこぼす方向にも効く。「埋め込みソースは本番より古いことがある。着手前に実機の現物とdiffする」という規律が、今回もそのまま効きました。

同じ機会に、ずっと放置していたバージョン表記のズレも片付けました。docstringのタイトルは v6.6.0 なのに FastAPI(version=)/ の返却値は 6.5.0 のまま、さらに変更履歴に履歴フォワード機能の記載が抜けている ― という三重のドリフト。今回 6.7.0 に統一し、抜けていた履歴も補完しました。

「流用すればいい」と思っていたものが、実機には無かった

設計の段階で、通知先のTalkルームは「DBバックアップ(pg_backup)が使っているのと同じ固定ルームを流用すればいい」と書いていました。最小の追加で済むはずでした。

ところが実装時に pg_backup.sh を実機で確認すると、grepに引っかかるのはコメント行だけ。中身はmarsのNextcloudデータディレクトリへファイルを置くだけで、Talkのチャットには一切投稿していませんでした。つまり「流用元のルーム」も「投稿用の認証情報」も、最初から存在しなかった。設計時の思い込みです。

というわけで、通知用のTalkルーム coordinator-notify を新規に作るところからになりました。ここからしばらく、Coordinatorのコードとは無関係な「配線」の沼が続きます。

沈黙の失敗:環境変数を足し忘れていた

コードを配備して通知を試すと、ログに enabled=False。通知ループは起動しているのに無効状態です。systemctl show coordinator -p Environment | grep NOTIFY は空。原因は単純で、systemdのoverride.confに NOTIFY_ENABLEDNC_TALK_* の行を追記していなかった。既存のLLM/メモリ系6行はそのまま生きていて、新しい5行を足し忘れていただけ。

これはこのプロジェクトで何度も踏んでいる「沈黙の失敗」の典型です。エラーは出ない、サービスは起動する、でも意図した動作はしない。切り分けは機械的にやります。grep -c NOTIFY_ENABLED override.conf0 ならファイル側で未追記、systemctl show -p Environment に出なければプロセス側に未反映。今回は前者でした。追記して daemon-reloadrestartenabled=True確認は必ず実効値で取る、というのが教訓の繰り返しです。

@化けの罠:パスワードの記号がシェルで消える

Talkへの疎通テストが、最初どうしても 401(認証失敗)になりました。ユーザー名もパスワードも合っているのに通らない。犯人はパスワードに含まれる @ などの記号が、curl -u 'user:pass' の解釈やシェルで化けていたことでした。

確実な渡し方に変えて解決しました。

read -rs NCUSER_PW          # 画面に出ない。user:apppass を1行入力
curl -sS -H 'OCS-APIRequest: true' \
  -u "$NCUSER_PW" \
  -d 'message=疎通テスト' \
  'https://nextcloud.mapleharp.jp/ocs/v2.php/apps/spreed/api/v1/chat/<TOKEN>'

read -rs なら記号がそのまま変数に入り、画面にも履歴にも残りません。あわせて、ログインパスワードそのものではなくアプリパスワード(Nextcloudの設定→セキュリティで発行する用途別パスワード)を使うようにしました。これでようやくTalkに「疎通テスト」が届きました。

計測してから信じる ― 段階0から4まで

配線が通ったので、依存の小さい順に実機で検証しました。このプロジェクトの基本姿勢「計測してから信じる」のとおり、一段ずつグリーンを確認します。

  • 段階0(起動健全性)/version 6.7.0 を返し、reaperと通知ループが両方起動。
  • 段階1(既存機能の非破壊)POST /ingest_memory{"queued":true}。reverse-staleで消しかけたエンドポイントが無事に生きていることの確認 ― 今回いちばん大事な一点。
  • 段階2(通知ループ単体):既存のdoneタスクへ /task/<id>/notifyarmed:true → 数秒後にTalkへ「✅完了」着弾 → 完了待ち集合が空に戻る。
  • 段階3(実離脱):猶予を10秒に縮めて長文生成を投げると、実行10秒で「⏏️ バックグラウンドに切り替えました」が表示され、裏で処理が続き、done後にTalkへ着弾。本文が長いと「…(全文はOpenWebUI/PG)」と切り詰められることも確認。
  • 段階4(二重配信なし):猶予を60秒に戻して短い質問を投げると、その場でインライン回答され、Talkは鳴らない。短いタスクは離脱しない=通知も登録されないので、二重に届くことが構造的に起きない。

全段階グリーン。長時間タスクが、画面を閉じても裏で走り続け、終わったらスマホのNextcloud Talkにちゃんと「✅完了」が飛んでくる。狙いどおりの受け皿ができました。

残した宿題

正直に書いておくと、離脱したタスクは「会話のライブ記憶」への書き込みをスキップします(応答を返す前に離脱してしまうため)。夜間バッチがグローバル記憶として拾い直すので致命的ではありませんが、スレッド単位のライブ記憶だけは離脱タスクで取りこぼす。これは通知ループ側でdone検出時に拾わせれば回収できるので、次の改善候補です。アプリパスワードをoverrideに平文で置いている点(権限は絞ってあります)も、いずれもっと安全な保管へ寄せたいところです。

機能としては小さな追加でしたが、「実機を見る」「計測してから信じる」「沈黙の失敗を疑う」という、この連載でずっと言い続けている規律が、設計時の思い込みを3つ(古い付録・存在しない流用元・足し忘れた環境変数)静かに潰してくれた回でした。次はこの通知ループの上に、依頼を分解して並列に走らせる仕組みを載せていきます。