diff --git a/server/services/ollamaManager.js b/server/services/ollamaManager.js index 0a038b61f9..b88fe9c7f8 100644 --- a/server/services/ollamaManager.js +++ b/server/services/ollamaManager.js @@ -34,7 +34,7 @@ import { buildHfAuthHeaders, buildHfResolveUrl, HF_API } from '../lib/huggingfac import { isEmbeddingModel } from '../lib/localModelHeuristics.js' import { commandExists } from '../lib/commandExists.js' import { - OLLAMA_AGENT_MIN_CONTEXT, resolveOllamaContextLength, withOllamaContextEnv + OLLAMA_AGENT_MIN_CONTEXT, OLLAMA_CONTEXT_ENV_VAR, resolveOllamaContextLength, withOllamaContextEnv } from '../lib/ollamaContext.js' import { compareSemver } from '../lib/versionUtils.js' import { isSafeHfRepoRelativePath } from '../lib/hfCache.js' @@ -640,7 +640,19 @@ async function getRuntimeContextLength(selectedModel = null) { async function ensureContextWindow(contextLength, selectedModel = null) { const target = Number(contextLength) > 0 ? Math.floor(Number(contextLength)) : null if (!target) return { applied: false, reason: 'not-configured', contextLength: null } - const env = withOllamaContextEnv({}, target) + + // Compose the context-window env ON TOP OF the currently applied launch env + // instead of replacing it, so a reload preserves knobs (e.g. + // OLLAMA_FLASH_ATTENTION, OLLAMA_KV_CACHE_TYPE) that are not about the window. + // `appliedLaunchEnvValues` outlives `appliedLaunchEnv` specifically to answer + // "what has PortOS put in front of Ollama that has not been cleared yet", so + // an active tuning is preserved even if the daemon was temporarily down. + // When a tuning is active mid-sweep, preserving its knobs keeps the sweep's + // measurements comparable rather than demoting the daemon to untuned + // mid-sweep, while allowing the context window to expand for the harness. + const baseEnv = appliedLaunchEnvValues ? { ...appliedLaunchEnvValues } : {} + const env = withOllamaContextEnv(baseEnv, target) + if (!(await checkOllamaAvailable(true))) { return { ...(await restartWithEnv(env, { tuning: false })), contextLength: target } } @@ -817,10 +829,23 @@ async function restartWithEnv(env, { tuning = true } = {}) { // by construction), so anything written up front would be wiped mid-call. // // A restart that did not happen changed nothing, so the bookkeeping must not - // move either. A non-tuning restart that DID happen is the install's real - // configuration changing, which leaves nothing to undo. - if (result.applied === false) preTuningEnv = before - else preTuningEnv = tuning ? captured : null + // move either. + // + // When a non-tuning restart (`tuning: false`, e.g. `ensureContextWindow`) occurs + // while a tuning is active (`before !== null`), preserve the pre-tuning undo + // baseline rather than clearing it, but update its context window so that a + // later `clearLaunchEnv()` restores the untuned daemon with the new context + // length rather than reverting it. If no tuning was active (`before === null`), + // `preTuningEnv` remains `null`. + if (result.applied === false) { + preTuningEnv = before + } else if (tuning) { + preTuningEnv = captured + } else if (before) { + preTuningEnv = withOllamaContextEnv(before, env[OLLAMA_CONTEXT_ENV_VAR]) + } else { + preTuningEnv = null + } return result } diff --git a/server/services/ollamaManager.test.js b/server/services/ollamaManager.test.js index d3d99163c8..7e43fa0b02 100644 --- a/server/services/ollamaManager.test.js +++ b/server/services/ollamaManager.test.js @@ -1164,6 +1164,102 @@ describe('ollamaManager.restartWithEnv', () => { expect(options.env.OLLAMA_CONTEXT_LENGTH).toBe('131072') }) + // A context-window reload (e.g. Claude Code spawn needing numCtx 131072) must + // compose on top of an active tuning's launch knobs (OLLAMA_FLASH_ATTENTION, + // OLLAMA_KV_CACHE_TYPE) instead of wiping them, and the tuning's undo + // bookkeeping must survive so clearLaunchEnv() can restore the untuned state. + it('preserves an active tuning across ensureContextWindow on a spawned process', async () => { + const state = { up: false, probesBeforeStop: 0 } + stubReachable({ get reachable() { return state.up || state.probesBeforeStop-- > 0 } }) + const spawn = await stubSpawnRestart(state) + const { ensureContextWindow, restartWithEnv } = await loadManager() + + await restartWithEnv({ OLLAMA_FLASH_ATTENTION: '1', OLLAMA_KV_CACHE_TYPE: 'q8_0' }) + state.up = false + state.probesBeforeStop = 3 + + const reload = await ensureContextWindow(131072) + expect(reload).toMatchObject({ applied: true, reason: 'restarted', contextLength: 131072 }) + + const lastSpawn = spawn.mock.calls[spawn.mock.calls.length - 1] + expect(lastSpawn[2].env.OLLAMA_FLASH_ATTENTION).toBe('1') + expect(lastSpawn[2].env.OLLAMA_KV_CACHE_TYPE).toBe('q8_0') + expect(lastSpawn[2].env.OLLAMA_CONTEXT_LENGTH).toBe('131072') + + // Clearing the tuning removes tuning keys and preserves the context window + state.up = false + state.probesBeforeStop = 2 + expect(await restartWithEnv({})).toMatchObject({ applied: null }) + const clearedSpawn = spawn.mock.calls[spawn.mock.calls.length - 1] + expect(clearedSpawn[2].env.OLLAMA_FLASH_ATTENTION).toBeUndefined() + expect(clearedSpawn[2].env.OLLAMA_KV_CACHE_TYPE).toBeUndefined() + expect(clearedSpawn[2].env.OLLAMA_CONTEXT_LENGTH).toBe('131072') + }) + + it('preserves an active tuning and its undo bookkeeping across a service context-window reload', async () => { + restorePlatform = pinPlatform('darwin') + stubReachable({ reachable: true }) + const calls = [] + execMock.impl = (cmd, args, opts, cb) => { + const a = (args || []).join(' ') + calls.push(`${cmd} ${a}`) + if (cmd === 'brew' && a === '--version') return cb(null, { stdout: 'Homebrew 4.0.0', stderr: '' }) + if (cmd === 'brew' && a === 'services list') return cb(null, { stdout: 'ollama started testuser plist\n', stderr: '' }) + if (cmd === 'ps') return cb(null, { stdout: '101 /usr/local/bin/ollama serve\n', stderr: '' }) + if (cmd === 'launchctl') return cb(null, { stdout: '', stderr: '' }) + if (cmd === 'brew' && a === 'services restart ollama') return cb(null, { stdout: '', stderr: '' }) + return cb(new Error(`unexpected exec: ${cmd} ${a}`)) + } + + const { ensureContextWindow, restartWithEnv } = await loadManager() + // 1. Apply tuning: flash attention + kv cache quantization + await restartWithEnv({ OLLAMA_FLASH_ATTENTION: '1', OLLAMA_KV_CACHE_TYPE: 'q8_0' }) + + // 2. Context window reload arrives for an agent spawn + calls.length = 0 + const reloaded = await ensureContextWindow(131072) + expect(reloaded).toMatchObject({ applied: true, reason: 'service-restarted' }) + + // Both tuning knobs and the new context window are passed to the daemon + expect(calls).toContain('launchctl setenv OLLAMA_FLASH_ATTENTION 1') + expect(calls).toContain('launchctl setenv OLLAMA_KV_CACHE_TYPE q8_0') + expect(calls).toContain('launchctl setenv OLLAMA_CONTEXT_LENGTH 131072') + expect(calls).not.toContain('launchctl unsetenv OLLAMA_FLASH_ATTENTION') + expect(calls).not.toContain('launchctl unsetenv OLLAMA_KV_CACHE_TYPE') + + // 3. Undo bookkeeping survived: clearing the tuning restores the baseline + // context window while unsetting the tuning keys + calls.length = 0 + const cleared = await restartWithEnv({}) + expect(cleared).toMatchObject({ applied: null }) + expect(calls).toContain('launchctl unsetenv OLLAMA_FLASH_ATTENTION') + expect(calls).toContain('launchctl unsetenv OLLAMA_KV_CACHE_TYPE') + expect(calls).toContain('launchctl setenv OLLAMA_CONTEXT_LENGTH 131072') + }) + + it('preserves an active tuning when ensureContextWindow starts an offline daemon', async () => { + const state = { up: false, probesBeforeStop: 0 } + stubReachable({ get reachable() { return state.up || state.probesBeforeStop-- > 0 } }) + const spawn = await stubSpawnRestart(state) + const { ensureContextWindow, restartWithEnv } = await loadManager() + + await restartWithEnv({ OLLAMA_FLASH_ATTENTION: '1', OLLAMA_KV_CACHE_TYPE: 'q8_0' }) + + // Daemon goes down + state.up = false + state.probesBeforeStop = 0 + + // Calling ensureContextWindow when the daemon is unreachable should still + // preserve the tuning knobs and start the daemon with them + const result = await ensureContextWindow(131072) + expect(result).toMatchObject({ applied: true, contextLength: 131072 }) + + const lastSpawn = spawn.mock.calls[spawn.mock.calls.length - 1] + expect(lastSpawn[2].env.OLLAMA_FLASH_ATTENTION).toBe('1') + expect(lastSpawn[2].env.OLLAMA_KV_CACHE_TYPE).toBe('q8_0') + expect(lastSpawn[2].env.OLLAMA_CONTEXT_LENGTH).toBe('131072') + }) + // The domain outlives the daemon, so a stopped Ollama is no reason to leave // the variables in it — the next login-launched one would inherit them. it('unsets exported variables even when Ollama is already down', async () => {