朝のログに、一行だけ残っていました。
「該当する機能が見つかりませんでした」。
前日まで問題なく動いていた定時タスクです。手元で同じ手順をなぞると、コネクタは何事もなく応答します。再実行すると成功する。翌朝また同じ一行が残る。この往復を何度か繰り返して、ようやく腑に落ちました。タスクは嘘をついていたわけではありません。タスクが見たその瞬間には、本当にそのコネクタが存在していなかったのです。
私自身、個人開発の運用でスケジュール実行を増やすほど、この種の「見た時刻に依存する失敗」に足を取られてきました。厄介なのは、失敗の見た目が全部同じになることです。応答しないコネクタを前にしたとき、機械は「無い」としか言えません。けれど無いことの中身は一つではありませんでした。
使えないコネクタは、三つの別々の事情を同じ顔で見せてくる
実行環境のコネクタ一覧を眺めていて気づいたのは、届かない理由が少なくとも三種類に分かれることでした。
一つ目は、まだ起動が終わっていない状態です。バックグラウンドで接続処理が進んでいて、数秒後には一覧に現れます。この状態のコネクタに対して「無い」と判定するのは、単に早すぎるだけです。
二つ目は、認証が切れている状態です。トークンの期限が過ぎている、あるいは初回の認可が済んでいない。サーバー自体は生きていて、名前も見えているのに、呼ぶと弾かれます。そして無人実行では、ここが決定的に効いてきます。認可のやり取りには人間のブラウザ操作が要るため、待っても永遠に直りません。
三つ目が、そもそも接続されていない状態です。設定から外れている、あるいは環境が変わって配信されなくなった。これも待っても現れません。ただし対処は二つ目とは別物で、こちらは代替経路を探すか、機能そのものを諦める判断になります。
同じ「使えない」でも、正しい振る舞いが三方向に分かれます。ここを一つに畳んでしまうと、待つべきときに諦め、諦めるべきときに待ち続けることになります。
| 状態 | 実体 | 時間で解決するか | 既定の振る舞い |
| connecting | 接続処理が進行中 | する(多くは数秒) | 予算内で待つ |
| unauthenticated | 認可が未了・期限切れ | しない(人の操作が必要) | 即座に人へ返す |
| absent | 未接続・配信されていない | しない | 代替経路か、機能を落とす |
三状態を分ける最小の判定器
判定に必要な材料は多くありません。実行時点で使えるツールの一覧、接続処理中のサーバー名、認証待ちのサーバー名。この三つが取れれば分類できます。純関数にしておくと、後でテストが書けます。
// resolver.mjs — コネクタの「使えなさ」を三状態に分類する
export const READY = "ready";
export const CONNECTING = "connecting";
export const UNAUTHENTICATED = "unauthenticated";
export const ABSENT = "absent";
// mcp__google-drive__search → "google-drive"
// 組み込みツール(Bash 等)はサーバーを持たないので "builtin"
export function serverOf(toolName) {
const m = /^mcp__([^_]+(?:_[^_]+)*?)__/.exec(toolName);
return m ? m[1] : "builtin";
}
export function classify(toolName, snapshot) {
const { readyTools, pendingServers, unauthenticatedServers } = snapshot;
// 1. 使えるなら、それ以上の詮索は不要
if (readyTools.includes(toolName)) return READY;
const server = serverOf(toolName);
// 2. 認証を先に見る。ここを後回しにすると「待てば直る」と誤判定する
if (unauthenticatedServers.includes(server)) return UNAUTHENTICATED;
// 3. 接続処理中なら、まだ結論を出さない
if (pendingServers.includes(server)) return CONNECTING;
// 4. どれでもなければ、この実行では存在しない
return ABSENT;
}
判定の順序に意味があります。認証チェックを接続チェックより先に置いているのは、認証待ちのサーバーが同時に「接続処理中」としても現れることがあるからです。順序を逆にすると、永遠に直らないものを延々と待つ実装になります。この一行の並び順が、後述する待ち時間の差をそのまま生みました。
待機予算を三状態で切り分けると、待ち時間の大半が消える
分類できたら、待つ対象を connecting だけに絞ります。予算とポーリング間隔を明示的に持たせ、予算を超えたら結論を返す。
// resolver.mjs(続き)— 予算付きの能力解決
export async function resolve(toolName, opts) {
const { snapshotOf, waitBudgetMs = 20000, pollMs = 2000, sleep } = opts;
const deadline = Date.now() + waitBudgetMs;
let waitedMs = 0;
for (;;) {
const state = classify(toolName, await snapshotOf());
if (state === READY) return { state, waitedMs };
// 時間で解決しない二つは、待たずに即座に返す
if (state === UNAUTHENTICATED || state === ABSENT) return { state, waitedMs };
// 次のポーリングが予算を超えるなら、ここで打ち切る
if (Date.now() + pollMs > deadline) {
return { state: CONNECTING, waitedMs, timedOut: true };
}
await sleep(pollMs);
waitedMs += pollMs;
}
}
sleep を引数で受け取っているのは、テストで仮想時計に差し替えるためです。実時間で待つコードはテストが書けず、書けないものは壊れても気づけません。
効果を確かめるために、12個のツールを使う想定でシミュレーションを組みました。うち7つは最初から使え、1つは4回目のポーリングで現れ、1つは最後まで接続処理中のまま、3つは認証待ち。待機予算20秒・ポーリング間隔2秒という条件です。
比較したのは二つの実装です。A は三状態を区別せず「一覧に無いものはこれから来るかもしれない」として一律に待つもの。B は上記の分類を通すもの。
A 区別なし: 総待機 86000 ms / ready 8件
B 三状態 : 総待機 26000 ms / ready 8件
差: 60000 ms ( 69.8 % 短縮 )
最終的に使えたツールの数はどちらも8件で同じです。A が余分に費やした60秒は、一つも成果を生んでいません。認証待ちの3つを待った60秒(20秒 × 3)が、まるごと無駄になっていたわけです。総待機時間にして 69.8%、この条件では待ち時間のおよそ7割が、成果に一切寄与しない時間でした。
この数値自体は仮想時計を使ったシミュレーションの結果であり、実環境ではポーリング間隔・予算・未認証コネクタの数で変わります。ただし構造は変わりません。未認証のコネクタが増えるほど、区別しない実装の待ち時間だけが線形に伸びていきます。未認証が1件増えるごとに、予算そのままの20秒が加算される計算です。しかも増えた分は全部捨て時間です。
予算の初期値を決めかねる場合は、待機予算 15〜20秒・ポーリング間隔 2秒あたりから始めることをお勧めします。私自身もこの値で運用しています。予算を60秒まで伸ばしても回収できたのは接続の遅い1件だけで、実行時間の上限が近い定時タスクでは割に合いませんでした。逆に5秒まで詰めると、起動に4回のポーリング(6秒)を要したコネクタを取りこぼします。この6秒という実測値が、予算の下限を決める根拠になりました。
能力の確認を「実行の入口」から「使用の直前」へ移す
三状態を入れても、確認する場所が間違っていると効きません。よくある書き方は、実行の冒頭で使えるツールを一度だけ調べ、その結果を全体で使い回すものです。
// ❌ 入口で固定してしまう — 起動が遅いコネクタを永久に見失う
const available = await listTools();
const canUseDrive = available.includes("mcp__google-drive__search");
// ...この後 10 分かけて別の処理...
if (canUseDrive) { /* 実際にはここに来る頃には使えるようになっている */ }
実行環境によっては、コネクタは実行開始後にも順次立ち上がります。入口のスナップショットは、その瞬間の写真でしかありません。10分後に使う判断を、10分前の写真で下している構図です。
書き換えの要点は単純で、判断を使う直前まで遅らせることです。
// ✅ 使う直前に解決する。結果は短時間だけキャッシュする
const cache = new Map();
async function capability(toolName, ttlMs = 60000) {
const hit = cache.get(toolName);
if (hit && Date.now() - hit.at < ttlMs) return hit.value;
const value = await resolve(toolName, { snapshotOf, sleep: realSleep });
// 「使える」と「恒久的に無い」は寿命が長い。
// connecting は次回また変わりうるので短命にしておく
const at = value.state === CONNECTING ? Date.now() - ttlMs + 5000 : Date.now();
cache.set(toolName, { value, at });
return value;
}
キャッシュの寿命を状態ごとに変えているところが実務的な肝です。ready と absent は数十秒単位で安定しますが、connecting は次の瞬間に ready へ変わる可能性があります。同じ寿命を与えると、せっかく立ち上がったコネクタを一定時間見失い続けることになります。
未認証は待っても直らない、という無人実行に固有の非対称性
対話しながら作業しているときは、認証切れは些細な出来事です。画面に案内が出て、ブラウザで承認して、数十秒で戻ってきます。
無人実行では、この経路が丸ごと存在しません。認可のやり取りは人間のブラウザ操作を前提としているため、タスクの中からは開始すらできない。ここに気づかないまま「一時的な障害だから再試行しよう」と設計すると、再試行の回数だけ実行時間が伸び、最後は同じ場所で失敗します。
だから未認証は、失敗ではなく引き継ぎとして扱うのが正確です。私は次の三点をタスク側の既定にしています。
- 未認証を検出したら、そのコネクタに依存する工程だけを即座に打ち切る(全体は止めない)
- 依存しない工程は最後まで走らせ、成果を残す
- 終了時の出力に「どのコネクタが、どの状態で、何を妨げたか」を明示する
3番目が抜けると、受け取る側は結局ログを掘ることになります。無人実行の出力は、次に人が読むときの引き継ぎ書だと考えると、書くべきことが決まってきます。この考え方はClaudeが安全上の理由で応答を断ったとき、無人パイプラインは何を返すべきかで扱った「断られたときに何を返すか」と同じ筋です。断られる理由が安全上の判断か、認証の欠落かの違いにすぎません。
迂回するか、止めるか、人に返すか
状態が三つに分かれたら、工程ごとの既定の振る舞いも決めておきます。ここを毎回その場で考えると、実装がぶれます。私はこのテーブルを工程の宣言と同じ場所に置き、コードを読む人が振る舞いを推測しないで済むようにしています。
// 工程の宣言。必須かどうかと、代替経路の有無を先に決めておく
const steps = [
{ id: "fetch-metrics", tool: "mcp__analytics__query", required: true, fallback: null },
{ id: "post-summary", tool: "mcp__chat__send", required: false, fallback: "write-file" },
{ id: "archive", tool: "mcp__storage__put", required: false, fallback: "write-file" },
];
async function planStep(step) {
const { state } = await capability(step.tool);
if (state === READY) return { action: "run" };
if (state === UNAUTHENTICATED) {
// 待っても直らない。必須なら止め、任意なら代替へ
return step.required
? { action: "abort", reason: `${step.tool} の認証が必要です` }
: { action: "fallback", to: step.fallback, reason: "認証未了のため代替経路へ" };
}
if (state === CONNECTING) {
// 予算を使い切ってなお接続中。次回の実行で回復する見込みがある
return step.required
? { action: "retry-next-run", reason: "起動待ちのまま予算超過" }
: { action: "skip", reason: "起動待ちのため今回は省略" };
}
// absent
return step.required
? { action: "abort", reason: `${step.tool} が接続されていません` }
: { action: "fallback", to: step.fallback, reason: "未接続のため代替経路へ" };
}
unauthenticated と absent で必須工程の扱いを同じ abort にしつつ、理由の文言を変えているのは意図的です。受け取る人がやるべきことが違うからです。前者は認可をやり直す、後者は接続を追加する。同じ「止まりました」でも、次の行動が一意に決まる文を残せるかどうかで、復旧までの時間が変わります。
ログに残すのは「できなかった」ではなく「どの状態でできなかったか」
三状態を導入して一番効いたのは、実は実行時間の短縮ではありませんでした。ログが読めるようになったことです。
// 実行の最後に、状態別の内訳を必ず1レコード書く
function summarize(decisions) {
const byState = decisions.reduce((acc, d) => {
acc[d.state] = (acc[d.state] || 0) + 1;
return acc;
}, {});
return {
ts: new Date().toISOString(),
ran: decisions.filter(d => d.action === "run").length,
blocked_by_auth: decisions
.filter(d => d.state === "unauthenticated")
.map(d => serverOf(d.tool)),
still_connecting: decisions
.filter(d => d.state === "connecting")
.map(d => serverOf(d.tool)),
absent: decisions.filter(d => d.state === "absent").map(d => serverOf(d.tool)),
byState,
};
}
出力例はこうなります。
{
"ts": "2026-08-23T03:00:12.481Z",
"ran": 8,
"blocked_by_auth": ["analytics", "chat", "storage"],
"still_connecting": ["search"],
"absent": [],
"byState": { "ready": 8, "unauthenticated": 3, "connecting": 1 }
}
「該当する機能が見つかりませんでした」という一行と比べると、次にやることが即座に決まります。blocked_by_auth に名前が並んでいれば、認可をやり直せば明日は動く。still_connecting だけなら、待機予算を少し伸ばすか、その工程を実行順の後ろへ回せばよい。absent が並んでいたら、設定そのものを見直す番です。
無人実行の記録をどう残すかについては、「成功」と記録されたのに成果がゼロだった — Cowork スケジュールタスクの無音失敗を終了前アサーションで止めるで終了前アサーションの形を整理しています。状態の内訳を書き出す本稿の仕組みは、そのアサーションに渡す材料としてそのまま使えます。
実装して初めて見えた、二つの落とし穴
想定と違ったことが二つありました。
一つ目は、リトライを厚くするほど成功率が下がる工程があったことです。直感的には、再試行を増やせば拾える機会は増えるはずです。ところが認証待ちのコネクタに対する再試行は、一件も拾わないまま実行時間だけを食います。実行時間には上限があるため、前半の無駄な待機が後半の正当な工程を押し出す。再試行を増やしたことで、全体の完了件数が減るという逆転が起きていました。区別のない待機は、単に遅いのではなく、他の工程から時間を奪います。
二つ目は、ツールの一覧が実行の途中で増えることを、テストで再現していなかったことです。手元のテストでは一覧を固定していたので、connecting から ready へ遷移する経路が一度も通っていませんでした。仮想時計を入れて「4回目のポーリングで現れる」ケースを書いたところ、キャッシュの寿命設定に問題が見つかりました。立ち上がった直後のコネクタを、古いキャッシュのせいで一定時間見失っていたのです。
// 仮想時計で「遅れて現れる」を再現する
let now = 0;
const sleep = ms => { now += ms; return Promise.resolve(); };
Date.now = () => now; // テストの中だけで差し替える
let polls = 0;
const lateArrival = async () => {
polls++;
return polls >= 4
? { readyTools: ["mcp__x__a"], pendingServers: [], unauthenticatedServers: [] }
: { readyTools: [], pendingServers: ["x"], unauthenticatedServers: [] };
};
// → { state: 'ready', waitedMs: 6000 } polls = 4
console.log(await resolve("mcp__x__a", { snapshotOf: lateArrival, sleep }));
実時間で待つテストは遅いので書かなくなり、書かないので遷移の経路が検証されない。仮想時計はその連鎖を断ち切るためのものです。6秒待って ready を返す挙動が、ミリ秒で検証できます。
停止と復旧の設計全般については、Cowork スケジュールタスクが黙って止まる理由と、自動で立ち直る仕組みの作り方も併せて参考になるはずです。
まず一つだけ試すなら
いま動かしている無人タスクのログを開いて、「使えませんでした」に相当する行を探してみてください。そこにコネクタ名と状態が書かれていなければ、最初の一歩はその一行を書き足すことです。
判定器も予算もキャッシュも、後から足せます。けれど記録に残っていない状態は、後から遡れません。私自身、三状態の分類にたどり着けたのは、原因が分からないまま「何が無かったのか」だけを書き出す習慣を先に作ったからでした。
まだ手探りの部分も多い領域ですが、無人で動く仕組みを育てていく上での土台になればと思います。お読みいただきありがとうございました。