From 41fa59fac42a790ea761551397163dda4a2e90ea Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" <70015+atomantic@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:16:08 +0000 Subject: [PATCH] fix: classify missing Ollama CLI startup failures --- server/lib/aiToolkit/errorDetection.js | 12 +++++++++++ server/lib/aiToolkit/runner.test.js | 9 +++++--- server/services/ollamaManager.js | 30 ++++++++++++++++++++------ server/services/ollamaManager.test.js | 24 +++++++++++++++++++++ 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/server/lib/aiToolkit/errorDetection.js b/server/lib/aiToolkit/errorDetection.js index edc1fe654f..57a3f86990 100644 --- a/server/lib/aiToolkit/errorDetection.js +++ b/server/lib/aiToolkit/errorDetection.js @@ -10,6 +10,7 @@ export const ERROR_CATEGORIES = { RATE_LIMIT: 'rate-limit', USAGE_LIMIT: 'usage-limit', AUTH_ERROR: 'auth-error', + SPAWN_ERROR: 'spawn-error', MODEL_NOT_FOUND: 'model-not-found', NETWORK_ERROR: 'network-error', TIMEOUT: 'timeout', @@ -145,6 +146,17 @@ const ERROR_PATTERNS = [ actionable: true, suggestedFix: 'Check API key configuration for this provider' }, + { + // A local CLI cannot be started when its executable is absent from the + // server process's PATH. This is a configuration/install problem, not an + // unknown provider failure: classifying it as spawn-error routes the + // diagnostic to the existing Tier-1 setup guidance. + pattern: /spawn\s+\S+\s+ENOENT|CLI is not installed or is not on PortOS's PATH/i, + category: ERROR_CATEGORIES.SPAWN_ERROR, + requiresFallback: true, + actionable: true, + suggestedFix: 'Install the local provider CLI and restart PortOS so its PATH includes the command.' + }, { // "model identifier is invalid" is Bedrock's wording when the runner passes // a model id the backend doesn't recognize (e.g. a bare Anthropic id like diff --git a/server/lib/aiToolkit/runner.test.js b/server/lib/aiToolkit/runner.test.js index 879d4b3252..18b7aadaf1 100644 --- a/server/lib/aiToolkit/runner.test.js +++ b/server/lib/aiToolkit/runner.test.js @@ -118,7 +118,10 @@ describe('AI Toolkit runner service', () => { endpoint: 'http://localhost:11434/v1', defaultModel: 'llama3' }; - const ensureProviderReady = vi.fn(async () => ({ success: false, error: 'service offline' })); + const ensureProviderReady = vi.fn(async () => ({ + success: false, + error: "Ollama CLI is not installed or is not on PortOS's PATH. Install Ollama from https://ollama.com/download, then restart PortOS." + })); const onComplete = vi.fn(); const onRunFailed = vi.fn(); const fetch = vi.fn(); @@ -153,8 +156,8 @@ describe('AI Toolkit runner service', () => { ); expect(metadata).toMatchObject({ success: false, - error: 'service offline', - errorCategory: 'unknown' + error: "Ollama CLI is not installed or is not on PortOS's PATH. Install Ollama from https://ollama.com/download, then restart PortOS.", + errorCategory: 'spawn-error' }); }); diff --git a/server/services/ollamaManager.js b/server/services/ollamaManager.js index 900ebd4609..d9673c91d8 100644 --- a/server/services/ollamaManager.js +++ b/server/services/ollamaManager.js @@ -310,13 +310,13 @@ function resetAvailabilityCache() { lastLoadedModelsError = null } -async function waitForAvailability(expected, timeoutMs) { +async function waitForAvailability(expected, timeoutMs, shouldAbort = () => false) { const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { + while (Date.now() < deadline && !shouldAbort()) { if ((await checkOllamaAvailable(true)) === expected) return true - await sleep(400) + if (!shouldAbort()) await sleep(400) } - return (await checkOllamaAvailable(true)) === expected + return !shouldAbort() && (await checkOllamaAvailable(true)) === expected } function rememberManagedProcess(child) { @@ -363,6 +363,13 @@ async function startServer({ env = null } = {}) { const contextLength = resolveOllamaContextLength(null, env || {}) let spawnError = null + let notifySpawnFailure = null + // `spawn()` reports a missing executable asynchronously. Waiting only for the + // HTTP probe in that case turns an immediate ENOENT into a 12-second startup + // timeout, obscuring the one useful diagnosis and needlessly delaying a + // fallback. Keep the probe for a real daemon startup, but let a spawn failure + // settle this attempt as soon as Node reports it. + const spawnFailed = new Promise((resolve) => { notifySpawnFailure = resolve }) const stderr = [] const child = spawn('ollama', ['serve'], { detached: true, @@ -374,10 +381,17 @@ async function startServer({ env = null } = {}) { stderr.push(chunk.toString()) if (stderr.join('').length > 2000) stderr.shift() }) - child.on('error', (err) => { spawnError = err }) + child.on('error', (err) => { + spawnError = err + notifySpawnFailure(err) + }) child.unref() - const running = await waitForAvailability(true, START_TIMEOUT_MS) + const startup = await Promise.race([ + waitForAvailability(true, START_TIMEOUT_MS, () => spawnError !== null).then((running) => ({ running, error: null })), + spawnFailed.then((error) => ({ running: false, error })), + ]) + const running = startup.running if (running) { // A daemon PortOS just started carries exactly `env` and nothing else, so // there is no earlier tuning left to undo. `restartWithEnv` re-asserts its @@ -390,7 +404,9 @@ async function startServer({ env = null } = {}) { return { success: true, running: true, pid: child.pid } } - const detail = spawnError?.message || stderr.join('').trim() + const detail = startup.error?.code === 'ENOENT' + ? 'Ollama CLI is not installed or is not on PortOS\'s PATH. Install Ollama from https://ollama.com/download, then restart PortOS.' + : spawnError?.message || stderr.join('').trim() return { success: false, running: false, diff --git a/server/services/ollamaManager.test.js b/server/services/ollamaManager.test.js index ce54f87136..2b84a89837 100644 --- a/server/services/ollamaManager.test.js +++ b/server/services/ollamaManager.test.js @@ -121,6 +121,30 @@ describe('ollamaManager residency status', () => { }) }) +describe('ollamaManager startup failures', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('reports a missing CLI immediately instead of waiting for the startup probe timeout', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('ECONNREFUSED') })) + const { spawn } = await import('../lib/childProcess.js') + spawn.mockReset().mockImplementation(() => ({ + pid: null, + stderr: { on: () => {} }, + on: (event, handler) => { + if (event === 'error') queueMicrotask(() => handler(Object.assign(new Error('spawn ollama ENOENT'), { code: 'ENOENT' }))) + }, + unref: () => {}, + })) + const { startServer } = await loadManager() + + await expect(startServer()).resolves.toEqual({ + success: false, + running: false, + error: 'Ollama did not become reachable: Ollama CLI is not installed or is not on PortOS\'s PATH. Install Ollama from https://ollama.com/download, then restart PortOS.' + }) + }) +}) + describe('ollamaManager model capability sentinel', () => { afterEach(() => vi.unstubAllGlobals())