Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m6s
CI/CD / lint (pull_request) Successful in 2m11s
CI/CD / test (pull_request) Successful in 1m21s
CI/CD / smoke (pull_request) Failing after 2m43s
CI/CD / build (pull_request) Failing after 3h11m43s
CI/CD / publish (pull_request) Has been cancelled
mcpctl hung on prompt-reading whenever the LLM misbehaved (thinking model = minutes; drifted model = silent failure). Root cause: the gate's begin_session prompt-selection called the LLM with no timeout, its fallback only fired on error and was silent, and it forced the project's vLLM model onto the anthropic heavy provider (so selection failed silently every time). - New withTimeout(run, ms, label): Promise.race + AbortSignal (fetch-based providers cancel). CompletionOptions.signal threaded into anthropic/openai. - Gate begin_session: LLM selection is time-bounded (MCPCTL_GATE_LLM_TIMEOUT_MS, 8s); on timeout/error it falls back to deterministic tag matching, logs [gate] loudly, prepends a ⚠ degraded note to the response, and sets degraded/degradedReason on the audit gate_decision. - Gate selector no longer forces the project vLLM model — uses the heavy provider's own model (fixes the always-silent-fail bug). - Pagination smart-index is time-bounded too (falls back to byte-range pages). - Chat (LLM-essential) surfaces the upstream status+body (names model+reason) instead of "Adapter returned no choice". - docs/reliability.md documents the principle. Tests: with-timeout unit tests; gate degradation test (error → visible ⚠ + deterministic prompts, no hang). mcplocal 737 + mcpd 945 green; tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
41 lines
1.4 KiB
TypeScript
41 lines
1.4 KiB
TypeScript
/**
|
|
* Bound an async operation with a timeout.
|
|
*
|
|
* Reliability principle: any LLM-*optional* operation (gate prompt-selection,
|
|
* pagination summaries) must be time-bounded so a slow/hung LLM (e.g. a
|
|
* thinking model that streams for minutes) can never block the caller. On
|
|
* timeout the returned promise rejects with a {@link TimeoutError}; callers are
|
|
* expected to fall back deterministically and report the degradation.
|
|
*
|
|
* The passed `AbortSignal` is aborted on timeout so fetch-based providers can
|
|
* cancel the in-flight request. Providers that ignore the signal are still
|
|
* unblocked immediately by the race (their orphaned request settles harmlessly
|
|
* in the background).
|
|
*/
|
|
export class TimeoutError extends Error {
|
|
constructor(label: string, ms: number) {
|
|
super(`${label} timed out after ${ms}ms`);
|
|
this.name = 'TimeoutError';
|
|
}
|
|
}
|
|
|
|
export async function withTimeout<T>(
|
|
run: (signal: AbortSignal) => Promise<T>,
|
|
ms: number,
|
|
label: string,
|
|
): Promise<T> {
|
|
const controller = new AbortController();
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const timeout = new Promise<never>((_, reject) => {
|
|
timer = setTimeout(() => {
|
|
controller.abort();
|
|
reject(new TimeoutError(label, ms));
|
|
}, ms);
|
|
});
|
|
try {
|
|
return await Promise.race([run(controller.signal), timeout]);
|
|
} finally {
|
|
if (timer !== undefined) clearTimeout(timer);
|
|
}
|
|
}
|