feat(reliability): bound + fail over + surface LLM-optional ops
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>
This commit is contained in:
Michal
2026-07-19 16:28:34 +01:00
parent d839d3c6cf
commit 485b8f613f
12 changed files with 241 additions and 34 deletions

View File

@@ -89,17 +89,30 @@ describe('LlmPromptSelector', () => {
expect(call.temperature).toBe(0);
});
it('passes model override to complete options', async () => {
const provider = makeMockProvider(
'{ "selectedNames": [], "reasoning": "" }',
);
it('does not force a model override — prompt-ranking uses the heavy provider\'s own model', async () => {
// Regression: forcing the project's vLLM model (e.g. deepseek-v4-*) onto the
// anthropic heavy provider made every selection fail silently. The selector
// must leave `model` unset so it uses whatever the heavy provider serves.
const provider = makeMockProvider('{ "selectedNames": [], "reasoning": "" }');
const registry = makeRegistry(provider);
const selector = new LlmPromptSelector(registry, 'gemini-pro');
const selector = new LlmPromptSelector(registry);
await selector.selectPrompts(['test'], sampleIndex);
const call = (provider.complete as ReturnType<typeof vi.fn>).mock.calls[0]![0] as CompletionOptions;
expect(call.model).toBe('gemini-pro');
expect(call.model).toBeUndefined();
});
it('forwards an abort signal into complete (for the gate timeout)', async () => {
const provider = makeMockProvider('{ "selectedNames": [], "reasoning": "" }');
const registry = makeRegistry(provider);
const selector = new LlmPromptSelector(registry);
const ac = new AbortController();
await selector.selectPrompts(['test'], sampleIndex, undefined, ac.signal);
const call = (provider.complete as ReturnType<typeof vi.fn>).mock.calls[0]![0] as CompletionOptions;
expect(call.signal).toBe(ac.signal);
});
it('throws when no heavy provider is available', async () => {