成功なのに走らない|Coworkタスク起動の二重障害

成功なのに走らない|Coworkタスク起動の二重障害

「APIは成功を返しているのに、実際には何も起きていない」。

この手の不具合ほど、原因の見当がつきません。

エラーメッセージという手がかりが、そもそも出てこないからです。

私が普段触っているのは Cowork のアーティファクトという自己完結 HTML の置き場で、そこに並べたボタンから定期タスクを起動する運用を組んでいます。

ある時期から、そのボタンが押しても何も起きない状態に変わりました。

それでも呼び出し側のログは、毎回きれいに「成功」で埋まっている

「成功したはずなのに走っていない」という状態を、どこから切り分けますか?

先に答えを書くと、独立した二つの故障が同時に起きていたというのが観測の結論でした。

前提と症状:成功ログなのに一つも走らない

このダッシュボードが叩いていたのは、タスクIDを渡して定期タスクを起動する API と、MCP ツールを呼び出す API の二つです。

前者が実行役で、後者はタスク一覧を取ってくる情報取得役、という役割分担でした。

症状は、最初から全滅していたわけではありません。

当初は5件の同時起動が通っていて、次第に先頭以外が取りこぼされ、最後は5件すべてが成功ログなのに1つも走らない状態まで悪化します。

しかも、アプリを起動した直後ほど失敗しやすいという偏りもありました。

単発で1件だけ起動したあとなら、続けて押した分が通ることもあるのです。

この「たまに通る」という揺らぎこそ、振り返れば最大のノイズ。

推測で当てたパッチが、ことごとく外れる

まず疑ったのは、同時に5件を投げていることでした。

そこで順次起動に変え、さらに1件ごとに待ち時間を入れてみます。

結果は改善どころか悪化で、間隔を空けるほど後続が弾かれるという逆の傾向まで出ました。

心当たりが一つ。

ブラウザにはユーザー操作の直後だけ特定の機能を許す一時的な有効期間があり、待ち時間を挟むとその期間が切れてしまいます。

この仕組みは Transient activation という名前で整理されているものです。

ところが、待たない実装に戻しても症状は消えません。

次に「準備できるまでボタンを無効化する」ゲートを足しても、やはり再発しました。

三度外したところで、ようやく考え方を切り替えます。

推測で直して外れ続けるのは、観測データが足りない証拠、と割り切りました。

観測点をアプリの中に置く

外から中身が見えないランタイムでは、内側にログを置く以外に手がありません。

そこでダッシュボード自身に、簡単な診断ログを実装します。

記録するのは、ページ表示からの経過秒・呼び出しごとの成否・エラー本文・応答時間の4点です。

記録した項目と設計意図

経過秒を入れた理由は、アプリ起動からの経過時間と失敗率に関係があると疑っていたから。

エラー本文を残したのは、成否のフラグだけでは何も判断できないためです。

応答時間は、「即座に返る成功」と「処理された成功」を見分けるために取りました。

実装としては、例えば以下のようなイメージになります。

const PAGE_T0 = Date.now();
const DIAG = [];
function diag(msg){
  const t = ((Date.now() - PAGE_T0) / 1000).toFixed(1);
  DIAG.push(`[+${t}s] ${msg}`);
  // 画面下部の <pre> に随時表示
}
try{
  await runTask(id);
  diag(`起動要求 ${id} 成功`);
}catch(e){
  diag(`起動要求 ${id} 失敗: ${e.message}`);
}
// 省略

大げさな仕組みは要りません。

画面の下に小さなログ欄を一つ足すだけで、必要な材料はそろいました。

ログが吐いた真実は、原因が一つではないこと

仕込んだあとのログは、想像よりずっと雄弁でした。

+0.3秒でツール呼び出しがエラー、+1.9秒で5件の起動要求がすべて一瞬で成功、+32.5秒でも一覧取得は不可

5件が同じ瞬間にそろって成功している時点で、これは実行の完了ではありません。

返ってきた「成功」は、要求を受け付けたという意味でしかなかったわけです。

ここから、原因が二つに割れました。

原因①:受け取っただけで捨てられる

起動 API は投げっぱなしの設計で、受け側が動いていなくても「受理」で成功を返します。

アプリを立ち上げた直後には、タスクの実行役がまだ起きていない時間帯が存在するのです。

その窓に入った要求は、エラーも残さず静かに消えていました。

原因②:健全なはずのプローブが壊れていた

もう一つは、準備確認に使っていたツール呼び出しそのものの故障です。

エラー本文を記録するようにして初めて、恒常的に 400 を返し続けていると分かりました。

宣言し直しても承認し直しても回復せず、権限の紐付けが切れている可能性が高いと見ています。

皮肉なのは、この壊れたプローブを準備判定に使っていたせいで、自作のゲートが健全な起動まで塞いでいたこと。

決め手は、一覧取得が失敗し続ける状態のまま手動でゲートを外し、起動ボタンを押した瞬間でした。

タスクは、あっさり走り出します。

起動の経路と、状態を見るプローブは独立に壊れるという実証になりました。

プローブが壊れても動く形に落とす

最終的な設計は、プローブを信じ切らない形に落ち着きます。

生きていればそれで即座に有効化し、400 を3回検知したらプローブを見切るという分岐を入れました。

見切ったあとは、ページ表示から20秒のウォームアップを置いて自動で有効化する流れです。

起動直後の危ない窓を、時間で避けるという割り切り。

加えて、いつでも押せる手動オーバーライドと、低頻度の回復検知ポーリングを残します。

そしてもう一つ、成功レスポンスを鵜呑みにしない検証を足しました。

起動要求の30秒後に一覧を取り直し、最終実行時刻がクリック時刻より新しいかを見る仕組みです。

例えば、次のようなイメージになります。

function verifyLaunches(ids){
  const clickAt = Date.now();
  setTimeout(async () => {
    const tasks = await fetchTasks(); // 一覧を再取得
    for (const id of ids){
      const t = tasks.find(x => x.taskId === id);
      const started = t && t.lastRunAt &&
        new Date(t.lastRunAt).getTime() >= clickAt - 60000;
      diag(`${id} → ${started ? "実行開始を確認" : "受理されたが未実行"}`);
    }
  }, 30000);
}
// 省略

これで「受理されたが未実行」を機械的に拾えるようになりました。

ただし一覧取得はプローブと同じ経路を使うので、プローブが復旧しないと検証も動きません

最後に

今回いちばん効いたのは、直す前に測ったこと。

非同期の API が返す「成功」が何を保証しているのかは、受理までなのか実行完了までなのかで意味がまるで変わります。

そして、状態を確かめるプローブと実際に動かすアクチュエータは、別々に壊れるものです。

片方の故障をもう片方の判定材料に使っている設計は、静かに自分の首を絞めます。

再現しない不具合は、直す前にエラー本文と時刻を残す

環境が Cowork でなくても、この順番だけは持ち帰れるはず。

挙動が変わりうる土台の上で動かす以上、観測点は外さずに残しておこうと思います。

以上です。