Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions server/lib/aiToolkit/errorDetection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions server/lib/aiToolkit/runner.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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'
});
});

Expand Down
30 changes: 23 additions & 7 deletions server/services/ollamaManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions server/services/ollamaManager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down