diff --git a/client/src/components/settings/LocalLlmRuntimesView.jsx b/client/src/components/settings/LocalLlmRuntimesView.jsx
index bfd9a5fbff..104e1df936 100644
--- a/client/src/components/settings/LocalLlmRuntimesView.jsx
+++ b/client/src/components/settings/LocalLlmRuntimesView.jsx
@@ -322,6 +322,13 @@ export default function LocalLlmRuntimesView() {
? `${idleRuntimeLabel[runtime] || runtime} will stay loaded while idle`
: `${idleRuntimeLabel[runtime] || runtime} releases its model after ${minutes} idle minute${minutes === 1 ? '' : 's'}`
).then(runtime === 'llama' ? loadLlamaStatus : runtime === 'slotstream' ? loadSlotstreamStatus : loadMtplxStatus);
+ const toggleKeepLoaded = (runtime, nextPinned) => runAction(
+ `runtime-keep-loaded-${runtime}`,
+ () => patchSettingsSlice(`localLlm.${runtime}`, { keepLoaded: nextPinned }),
+ nextPinned
+ ? `${idleRuntimeLabel[runtime] || runtime} is pinned and will stay loaded`
+ : `${idleRuntimeLabel[runtime] || runtime} will follow idle and pressure release policies`
+ ).then(runtime === 'llama' ? loadLlamaStatus : runtime === 'slotstream' ? loadSlotstreamStatus : loadMtplxStatus);
const runtimeInstallSlotstream = () => runAction(
'runtime-install-slotstream',
() => installSlotstream(),
@@ -656,6 +663,7 @@ export default function LocalLlmRuntimesView() {
onStopSlotstream={runtimeStopSlotstream}
onSaveStartup={saveRuntimeStartup}
onSaveIdleWindow={saveIdleWindow}
+ onToggleKeepLoaded={toggleKeepLoaded}
/>
{/* Backends — model catalog, default marker, cross-backend import */}
diff --git a/client/src/components/settings/RuntimeServersCard.jsx b/client/src/components/settings/RuntimeServersCard.jsx
index 46622d0165..0156c52c43 100644
--- a/client/src/components/settings/RuntimeServersCard.jsx
+++ b/client/src/components/settings/RuntimeServersCard.jsx
@@ -77,6 +77,7 @@ function pm2Row({ id, label, icon, status, platformReason, onStart, onStop, onIn
icon,
state,
endpoint: status?.endpoint || null,
+ releaseReason: !status?.running && status?.releaseReason ? status.releaseReason : null,
detail: platformReason || detail || null,
pm2: true,
runAtStartup: status?.runAtStartup ?? null,
@@ -190,6 +191,11 @@ function ServerRow({ row, busy, actionInProgress, children }) {
starts at boot
)}
+ {row.releaseReason && (
+
+ {row.releaseReason}
+
+ )}
{row.detail && {row.detail} }
@@ -296,6 +302,7 @@ export default function RuntimeServersCard({
onStopSlotstream,
onSaveStartup,
onSaveIdleWindow,
+ onToggleKeepLoaded,
}) {
// Read off each daemon's own status payload — the same place `runAtStartup`
// and Ollama's `disabled` come from — so there is no second settings fetch on
@@ -305,6 +312,11 @@ export default function RuntimeServersCard({
mtplx: mtplxStatus?.idleMinutes ?? 0,
slotstream: slotstreamStatus?.idleMinutes ?? 0,
};
+ const keepLoaded = {
+ llama: Boolean(llamaStatus?.keepLoaded),
+ mtplx: Boolean(mtplxStatus?.keepLoaded),
+ slotstream: Boolean(slotstreamStatus?.keepLoaded),
+ };
const ollamaService = status?.ollama?.service;
const ollamaRunsAtStartup = Boolean(ollamaService?.runAtStartup);
@@ -424,17 +436,32 @@ export default function RuntimeServersCard({
)}
{(row.id === 'llama' || row.id === 'mtplx' || row.id === 'slotstream') && row.state !== 'unsupported' && row.state !== 'missing' && (
-
onSaveIdleWindow?.(row.id, minutes)}
- note={row.id === 'llama'
- ? 'Minutes of PortOS inactivity after which llama.cpp unloads the model in place and reloads it on the next request. 0 = keep it resident. Applies from the next start.'
- : row.id === 'slotstream'
- ? 'Minutes of PortOS inactivity after which Slotstream is stopped. The next PortOS request starts it again on the same checkpoint and memory cap. 0 = keep it running.'
- : 'Minutes of PortOS inactivity after which MTPLX is stopped. The next PortOS request starts it again on the same checkpoint. 0 = keep it running.'}
- />
+
+ onSaveIdleWindow?.(row.id, minutes)}
+ note={keepLoaded[row.id]
+ ? `${row.label} is pinned to keep loaded and is exempt from idle release and memory pressure eviction.`
+ : row.id === 'llama'
+ ? 'Minutes of PortOS inactivity after which llama.cpp unloads the model in place and reloads it on the next request. 0 = keep it resident. Applies from the next start.'
+ : row.id === 'slotstream'
+ ? 'Minutes of PortOS inactivity after which Slotstream is stopped. The next PortOS request starts it again on the same checkpoint and memory cap. 0 = keep it running.'
+ : 'Minutes of PortOS inactivity after which MTPLX is stopped. The next PortOS request starts it again on the same checkpoint. 0 = keep it running.'}
+ />
+
+ onToggleKeepLoaded?.(row.id, !(keepLoaded[row.id] ?? false))}
+ className="rounded bg-port-bg border-port-border text-port-accent focus:ring-0 focus:ring-offset-0"
+ aria-label={`Keep ${row.label} loaded`}
+ />
+ Keep loaded
+
+
)}
{(row.id === 'llama' || row.id === 'mtplx' || row.id === 'slotstream') && row.state !== 'unsupported' && (
{
onStartSlotstream: vi.fn(),
onStopSlotstream: vi.fn(),
onSaveStartup: vi.fn(),
+ onToggleKeepLoaded: vi.fn(),
};
render(
@@ -338,6 +339,56 @@ describe('RuntimeServersCard', () => {
renderCard({});
expect(screen.getByText(/Only PortOS traffic counts/)).toBeInTheDocument();
});
+
+ it('shows the Keep loaded toggle and fires onToggleKeepLoaded when clicked', () => {
+ const handlers = renderCard({
+ mtplxStatus: { installed: true, running: true, supported: true, keepLoaded: false },
+ });
+ const checkbox = within(row('MTPLX')).getByRole('checkbox', { name: 'Keep MTPLX loaded' });
+ expect(checkbox).not.toBeChecked();
+
+ fireEvent.click(checkbox);
+ expect(handlers.onToggleKeepLoaded).toHaveBeenCalledWith('mtplx', true);
+ });
+
+ it('disables the idle release field when Keep loaded is checked', () => {
+ renderCard({
+ mtplxStatus: { installed: true, running: true, supported: true, keepLoaded: true },
+ });
+ const checkbox = within(row('MTPLX')).getByRole('checkbox', { name: 'Keep MTPLX loaded' });
+ expect(checkbox).toBeChecked();
+ expect(idleField('MTPLX')).toBeDisabled();
+ });
});
+ // ===========================================================================
+ // RELEASE REASON (MEMORY PRESSURE)
+ // ===========================================================================
+ describe('release reason display', () => {
+ it('shows host memory pressure release reason when daemon is stopped', () => {
+ renderCard({
+ slotstreamStatus: {
+ installed: true,
+ running: false,
+ supported: true,
+ releaseReason: 'released at 09:14 — host memory pressure',
+ },
+ });
+ const slotstream = row('Slotstream');
+ expect(within(slotstream).getByText('released at 09:14 — host memory pressure')).toBeInTheDocument();
+ });
+
+ it('hides release reason when daemon is running', () => {
+ renderCard({
+ slotstreamStatus: {
+ installed: true,
+ running: true,
+ supported: true,
+ releaseReason: 'released at 09:14 — host memory pressure',
+ },
+ });
+ const slotstream = row('Slotstream');
+ expect(within(slotstream).queryByText('released at 09:14 — host memory pressure')).toBeNull();
+ });
+ });
});
diff --git a/server/lib/README.md b/server/lib/README.md
index 8ece344019..bb0fec7429 100644
--- a/server/lib/README.md
+++ b/server/lib/README.md
@@ -184,7 +184,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `opencodeCatalogCache.js` | Primes the on-disk catalog `opencode models` prints from — `primeOpencodeCatalogCache()` fetches OpenCode's `api.json` with Node's fetch and atomically writes `$XDG_CACHE_HOME/opencode/models.json` (`~/.cache` when unset). OpenCode refreshes that file from a forked task whose failures it swallows (`opencode models --refresh` still prints `Models cache refreshed`) and its HTTP client has no Happy Eyeballs, so a host advertising an unreachable IPv6 default route freezes the catalog indefinitely while other machines on the same account list newer models. Refuses to fetch or write when `OPENCODE_MODELS_PATH` / a custom `OPENCODE_MODELS_URL` / `OPENCODE_DISABLE_MODELS_FETCH` means PortOS cannot be sure which file OpenCode reads, when the file is under five minutes old, or when the body did not parse as a catalog — a stale list beats an empty picker. Never throws; the caller probes either way. |
| `opencodeConfig.js` | OpenCode config builder — `buildOpencodeEnvVars(provider, model)` builds dynamic `OPENCODE_CONFIG_CONTENT` declaring model ids under the namespace the provider's marker selects: a local runtime (`ollama` / `mtplx` / `llama` / `vllm` / `sglang`, bare ids) or a hosted gateway from `providerGateways.js` (`vendor/model` ids kept whole). Fixes --model rejection. Also attaches the key for a key-bearing namespace, and pins `small_model` to the run model for a gateway so OpenCode's own side calls (titles, summarization) can't land on its built-in default — a billed model the operator never chose. Under a `no-tool` public-review profile it also applies `hardenOpencodeConfigForNoTool` — root `permission: deny`, an emptied tool map on every agent, `tool_call: false` on every declared model, and no MCP/plugins/share/autoupdate — which IS OpenCode's enforced tool-free recipe, since it ships no read-only argv flag (`providerVendors.js` pairs it with `run --agent` + `OPENCODE_PUBLIC_REVIEW_AGENT`). The harden step also copies `agent.build`'s generation settings onto that agent, so the stage's configured thinking effort reaches the model that actually runs. |
| `localProviderRuntime.js` | Which LOCAL daemon a provider talks to, and where — `LOCAL_RUNTIMES` (llama.cpp / Ollama / LM Studio / MTPLX / vLLM: label, binary, canonical base URL read from `opencodeConfig.js` rather than re-typed, manage/docs links, model-download hint), `localBackendForProvider` + `localEndpointPort` + `isLocalInstanceHost` (moved here from `services/localModelHealing.js`, which re-exports them, so the healing path and the readiness checklist classify a provider identically — loopback/bind-all only, so a LAN/Tailscale peer on port 11434 is NOT claimed as a local daemon), `localRuntimeKind(provider)` (the `*Backed` markers first, then that classifier; `orcarouter` excluded as a remote API), `modelPinIsOffered(provider, model)` (the ONE rule for validating a stored model pin against a provider record: an empty `models` array and a local daemon's cached snapshot are both pass-throughs, so a freshly pulled Ollama model is never rejected as "not offered"), `localRuntimeForProvider(provider)` → the row with the endpoint the provider ITSELF configures (`OPENCODE_CONFIG_CONTENT`'s `baseURL`, `ANTHROPIC_BASE_URL`, or `endpoint`), then the `OLLAMA_URL`/`OLLAMA_HOST`/`LM_STUDIO_URL` override the backend managers read, then the canonical default — and `null` when that resolved endpoint fails `isLocalInstanceEndpoint` (an API provider on another machine has no local daemon to check, whatever its name says) — plus `normalizeOpenAiBaseUrl`. Pure; the probing half is `services/providerReadiness.js`. Optional `setupStateDetail` overrides `providerReadiness`'s per-state prose for a runtime whose local setup is not a model cache (vLLM's is a compose project); `standbyWhenStopped` marks an installed runtime such as llama.cpp whose stopped state is intentional standby rather than incomplete setup. |
-| `managedDaemon.js` | Shared mechanism for the local daemons PortOS runs as optional PM2 processes (`services/llamaServerManager.js` → `portos-llama-server`, `services/mtplxServerManager.js` → `portos-mtplx`, `services/slotstreamServerManager.js` → `portos-slotstream`). Owns their PM2 process names — `LLAMA_APP`, `MTPLX_APP`, `SLOTSTREAM_APP`, and the `isModelServerProcess(name)` predicate over them — so a caller like the CoS health monitor can recognize a model server without importing a manager; the managers re-export those names. `createDaemonWatcher({...})` supplies the common PM2 launch-line re-adoption, endpoint probe, status skeleton, bounded log view, and port-release wait while managers retain daemon-specific parsing and lifecycle policy. `createDaemonLogBuffer({maxLines?})` is the bounded timestamped ring buffer of what PortOS logged around a launch, plus `withPm2Logs(output)` → that buffer followed by anything `pm2 logs` has which it doesn't already hold, deduped and re-capped (PM2's lines are a VIEW, never folded into the buffer — PM2 owns them and re-reads them every status call). `pm2ArgValue(args, flag)` reads one value back out of a PM2 process's recorded argv so a manager can recover a still-online daemon's launch config after a PortOS restart; `null` means the flag was absent, which a relaunch must leave off rather than defaulting. Also the shared **idle reaper**, for a daemon that cannot release its weights any other way: `registerIdleDaemon({name, getIdleMs, stop})` (seeds `lastUsedAt` to NOW, so a hand-started daemon gets a full window), `markDaemonUsed(name)` — call on real traffic, NEVER on a status poll — `daemonLastUsedAt(name)`, `idleWindowMs(minutes)` (minutes → ms; `0` = never, `null` = not configured, kept distinct), `reapIdleDaemons(now?)` → the names stopped, and `startIdleReaper({intervalMs?})` / `stopIdleReaper()` (ONE interval for all registrants, `unref`'d, idempotent). `mtplxServerManager` and `slotstreamServerManager` register: llama.cpp releases its checkpoint in place via `--sleep-idle-seconds` and must NOT be stopped for it. Deliberately mechanism only — what a launch line means and when a daemon may start is exactly what differs between the two. |
+| `managedDaemon.js` | Shared mechanism for the local daemons PortOS runs as optional PM2 processes (`services/llamaServerManager.js` → `portos-llama-server`, `services/mtplxServerManager.js` → `portos-mtplx`, `services/slotstreamServerManager.js` → `portos-slotstream`). Owns their PM2 process names — `LLAMA_APP`, `MTPLX_APP`, `SLOTSTREAM_APP`, and the `isModelServerProcess(name)` predicate over them — so a caller like the CoS health monitor can recognize a model server without importing a manager; the managers re-export those names. `createDaemonWatcher({...})` supplies the common PM2 launch-line re-adoption, endpoint probe, status skeleton, bounded log view, and port-release wait while managers retain daemon-specific parsing and lifecycle policy. `createDaemonLogBuffer({maxLines?})` is the bounded timestamped ring buffer of what PortOS logged around a launch, plus `withPm2Logs(output)` → that buffer followed by anything `pm2 logs` has which it doesn't already hold, deduped and re-capped (PM2's lines are a VIEW, never folded into the buffer — PM2 owns them and re-reads them every status call). `pm2ArgValue(args, flag)` reads one value back out of a PM2 process's recorded argv so a manager can recover a still-online daemon's launch config after a PortOS restart; `null` means the flag was absent, which a relaunch must leave off rather than defaulting. Also the shared **idle reaper**, for a daemon that cannot release its weights any other way: `registerIdleDaemon({name, getIdleMs, isPinned?, isRunning?, stop})` (seeds `lastUsedAt` to NOW, so a hand-started daemon gets a full window; `isPinned` exempts user-pinned servers), `markDaemonUsed(name)` — call on real traffic, NEVER on a status poll — `daemonLastUsedAt(name)`, `daemonReleaseReason(name)`, `idleWindowMs(minutes)` (minutes → ms; `0` = never, `null` = not configured, kept distinct), `evaluateMemoryPressurePolicy({...})` (pure policy function evaluating current state, pressure reading, and recent history), `reapIdleDaemons(now?, options?)` → the names stopped (stops idle daemons whose window elapsed, and runs a pressure-aware pass releasing the least recently used unpinned daemon under sustained host memory pressure; at most one daemon stopped per tick), and `startIdleReaper({intervalMs?})` / `stopIdleReaper()` (ONE interval for all registrants, `unref`'d, idempotent). `mtplxServerManager` and `slotstreamServerManager` register: llama.cpp releases its checkpoint in place via `--sleep-idle-seconds` and must NOT be stopped for it. Deliberately mechanism only — what a launch line means and when a daemon may start is exactly what differs between the two. |
| `mtplxModels.js` | `listMtplxCachedModels({command?})` → `{models, error}` from `mtplx models --json` (walks local directories — pulls no weights, loads no model, but see `mtplxRuntime.js`: on an un-warmed Homebrew wrapper the spawn ITSELF is a several-hundred-megabyte runtime download, so poll callers must gate on `describeMtplxRuntime().ready` first) and `pickMtplxCachedModel(models)` → the repo id to hand `mtplx serve --model`. `models: null` means the cache could not be READ (no binary, command failed, unparseable) and is deliberately distinct from `[]` (read, and empty), because `services/localRuntimeSetup.js` starts MTPLX on its own default in the first case and refuses with the `mtplx pull` command in the second. Exists because `mtplx serve` defaults `--model` to one hard-coded checkpoint and exits 1 before binding when that repo is not cached — even on a host holding a different MTP model that serves fine. Picks only entries MTPLX itself calls complete (`validation.ok !== false`, so a half-finished pull is not served), preferring one with a recorded `mtplx_runtime.json` exactness contract. `describeMtplxCache(cache)` → `{state: 'unknown'\|'empty'\|'partial'\|'ready', model, count, error}` folds both into the one value `services/providerReadiness.js` puts on the checklist and `describeRuntimeSetup` picks a button from — so an empty cache is named up front instead of only inside the failure of a Start that could never work. |
| `mtplxRuntime.js` | `describeMtplxRuntime(binaryPath, {env?})` → `{ready, wrapper, venvPath}` — is MTPLX's own Python runtime on disk, decided by READING the binary rather than running it. Homebrew's `mtplx` is a shell wrapper that lazily bootstraps a version-keyed Python venv (a multi-hundred-megabyte pip install) on its first invocation, and `brew upgrade` re-arms it, so a status poll or an 8s-judged PM2 start is really a package download. This parses the wrapper's own `VENV=` assignment, honours `$MTPLX_BREW_VENV` the way `${MTPLX_BREW_VENV:-…}` does, and tests `/bin/mtplx` for executability — the wrapper's own `[ ! -x … ]` guard, so the two cannot disagree. Anything unrecognisable (a pip install, a compiled binary, an unparseable script) reports `ready: true, wrapper: false`, i.e. the status quo — never a block over a parse failure. |
| `slotstreamCatalog.js` | `SLOTSTREAM_CATALOG` — the curated mixture-of-experts checkpoints worth streaming from SSD (the technique only pays off on MoE: a dense model of the same size would stream every layer for every token). `resolveSlotstreamRepo(idOrRepo)` maps a catalog id or an `owner/name` repo id to a repo (null for anything else), `slotstreamModelDirName(repo)` flattens it to the single-segment cache directory whose NAME is the id a start hands `--model`, and `selectSlotstreamRepoFiles(siblings)` picks the `.safetensors`/config/tokenizer files a checkpoint is made of while dropping mirrored `original/`, GGUF, ONNX, and PyTorch copies of the same weights (which can double a 100 GB+ pull) and any name that is not a plain relative path. |
diff --git a/server/lib/managedDaemon.js b/server/lib/managedDaemon.js
index f63d24250c..df84aac946 100644
--- a/server/lib/managedDaemon.js
+++ b/server/lib/managedDaemon.js
@@ -1,3 +1,5 @@
+import { getMemoryStats } from './memoryStats.js';
+
/**
* Shared plumbing for a local daemon PortOS runs as an optional PM2 process
* (`llamaServerManager.js` → `portos-llama-server`, `mtplxServerManager.js` →
@@ -190,6 +192,7 @@ export function createDaemonWatcher({
runAtStartup: savedApps === null ? null : savedApps.includes(appName),
recentLogs: logs.withPm2Logs(`${pm2Logs?.stdout || ''}\n${pm2Logs?.stderr || ''}`),
lastExitError: isReadFailed ? 'Failed to read PM2 status' : getLastExitError(),
+ releaseReason: isManagedActive ? null : (idleDaemons.get(appName)?.releaseReason ?? null),
};
};
@@ -234,10 +237,50 @@ export function createDaemonWatcher({
/** How often the reaper checks. Coarse on purpose — the windows are minutes. */
const IDLE_REAP_INTERVAL_MS = 60_000;
-/** name → `{ getIdleMs, stop, lastUsedAt }`. */
+/** Default free-memory threshold below which host memory is considered under pressure (4 GB). */
+export const DEFAULT_PRESSURE_THRESHOLD_BYTES = 4 * 1024 * 1024 * 1024;
+/** Dead-band: memory must rise above threshold + dead-band (4 GB + 2 GB = 6 GB) to exit pressure. */
+export const DEFAULT_PRESSURE_DEAD_BAND_BYTES = 2 * 1024 * 1024 * 1024;
+/** How long pressure must be sustained before a daemon is released early (30s). */
+export const DEFAULT_SUSTAINED_PRESSURE_MS = 30_000;
+/** Calm-down window after releasing a daemon before another daemon can be released (60s). */
+export const DEFAULT_PRESSURE_CALM_DOWN_MS = 60_000;
+
+/** Format a timestamp into HH:MM (e.g. 09:14) for human-legible release notes. */
+export function formatReleaseTime(timestamp = Date.now()) {
+ const d = new Date(timestamp);
+ const hh = String(d.getHours()).padStart(2, '0');
+ const mm = String(d.getMinutes()).padStart(2, '0');
+ return `${hh}:${mm}`;
+}
+
+/** name → `{ getIdleMs, isPinned, isRunning, stop, lastUsedAt, releaseReason, releasedAt }`. */
const idleDaemons = new Map();
let reaperTimer = null;
+let pressureHistory = [];
+let lastPressureReleaseAt = null;
+const MAX_PRESSURE_HISTORY = 100;
+
+export function recordPressureSample({ at = Date.now(), free, used, total }) {
+ pressureHistory.push({ at, free, used, total });
+ if (pressureHistory.length > MAX_PRESSURE_HISTORY) {
+ pressureHistory = pressureHistory.slice(-MAX_PRESSURE_HISTORY);
+ }
+}
+
+export function getPressureHistory() {
+ return [...pressureHistory];
+}
+
+export function getLastPressureReleaseTime() {
+ return lastPressureReleaseAt;
+}
+
+export function setLastPressureReleaseTime(time) {
+ lastPressureReleaseAt = time;
+}
+
/**
* A user-supplied idle window in minutes, as milliseconds.
*
@@ -271,16 +314,27 @@ export function idleWindowMs(minutes) {
* Re-registering the same name refreshes the hooks and leaves `lastUsedAt`
* alone, so a manager reloaded under test doesn't reset a live clock.
*
- * @param {{name: string, getIdleMs: () => Promise|number|null, stop: () => Promise}} daemon
- * `getIdleMs` resolves the CURRENT configured window on every sweep (so a
- * settings change takes effect without a restart); `null`/`0` = never stop.
+ * @param {{
+ * name: string,
+ * getIdleMs: () => Promise|number|null,
+ * isPinned?: () => Promise|boolean,
+ * isRunning?: () => Promise|boolean,
+ * stop: () => Promise
+ * }} daemon
+ * `getIdleMs` resolves the CURRENT configured window on every sweep; `null`/`0` = never stop.
+ * `isPinned` returns true if user pinned the server ("keep loaded") — pinned servers are never stopped.
+ * `isRunning` checks if the daemon process is online.
*/
-export function registerIdleDaemon({ name, getIdleMs, stop }) {
+export function registerIdleDaemon({ name, getIdleMs, isPinned, isRunning, stop }) {
const existing = idleDaemons.get(name);
idleDaemons.set(name, {
getIdleMs,
+ isPinned: typeof isPinned === 'function' ? isPinned : () => Boolean(isPinned),
+ isRunning: typeof isRunning === 'function' ? isRunning : null,
stop,
lastUsedAt: existing?.lastUsedAt ?? Date.now(),
+ releaseReason: existing?.releaseReason ?? null,
+ releasedAt: existing?.releasedAt ?? null,
});
}
@@ -290,14 +344,17 @@ export function registerIdleDaemon({ name, getIdleMs, stop }) {
* status poll: a status card that refreshes every few seconds would otherwise
* hold a 24GB checkpoint resident forever while nobody used it.
*
- * A no-op for an unregistered name, so a call site doesn't have to know whether
- * this install registered that daemon.
+ * Clears any prior release reason now that the server is active again.
*
* @param {string} name
*/
export function markDaemonUsed(name) {
const entry = idleDaemons.get(name);
- if (entry) entry.lastUsedAt = Date.now();
+ if (entry) {
+ entry.lastUsedAt = Date.now();
+ entry.releaseReason = null;
+ entry.releasedAt = null;
+ }
}
/** The recorded last-use timestamp for `name`, or `null`. Exposed for status cards. */
@@ -305,18 +362,164 @@ export function daemonLastUsedAt(name) {
return idleDaemons.get(name)?.lastUsedAt ?? null;
}
+/** The recorded release reason for `name`, or `null`. */
+export function daemonReleaseReason(name) {
+ return idleDaemons.get(name)?.releaseReason ?? null;
+}
+
+/** Explicitly clear release reason for `name`. */
+export function clearDaemonReleaseReason(name) {
+ const entry = idleDaemons.get(name);
+ if (entry) {
+ entry.releaseReason = null;
+ entry.releasedAt = null;
+ }
+}
+
/**
- * One sweep: stop every registered daemon whose window has elapsed.
+ * Pure policy function for memory pressure daemon eviction.
+ * Evaluates current state, pressure reading, and recent history.
*
- * Exported so a test can drive it directly instead of waiting on the timer, and
- * so a caller can force a sweep after a settings change.
+ * Returns `{ shouldRelease: boolean, target?: object, reason?: string, ... }`.
+ */
+export function evaluateMemoryPressurePolicy({
+ daemons = [],
+ memoryStats = null,
+ history = [],
+ now = Date.now(),
+ lastReleasedAt = null,
+ options = {},
+} = {}) {
+ const thresholdBytes = options.pressureThresholdBytes ?? DEFAULT_PRESSURE_THRESHOLD_BYTES;
+ const deadBandBytes = options.deadBandBytes ?? DEFAULT_PRESSURE_DEAD_BAND_BYTES;
+ const sustainedDurationMs = options.sustainedDurationMs ?? DEFAULT_SUSTAINED_PRESSURE_MS;
+ const calmDownMs = options.calmDownMs ?? DEFAULT_PRESSURE_CALM_DOWN_MS;
+
+ if (!memoryStats || typeof memoryStats.free !== 'number') {
+ return { shouldRelease: false, target: null, reason: 'memory stats unavailable' };
+ }
+
+ const free = memoryStats.free;
+ const wasUnderPressure = Boolean(options.wasUnderPressure ?? (lastReleasedAt && (now - lastReleasedAt < calmDownMs * 2)));
+ const exitThresholdBytes = thresholdBytes + deadBandBytes;
+ const isRelieved = wasUnderPressure
+ ? free >= exitThresholdBytes
+ : free >= thresholdBytes;
+
+ if (isRelieved) {
+ return {
+ shouldRelease: false,
+ target: null,
+ reason: 'host memory not under pressure',
+ free,
+ thresholdBytes,
+ exitThresholdBytes,
+ wasUnderPressure,
+ };
+ }
+
+ if (lastReleasedAt && (now - lastReleasedAt < calmDownMs)) {
+ return {
+ shouldRelease: false,
+ target: null,
+ reason: 'in calm-down window',
+ remainingCalmDownMs: calmDownMs - (now - lastReleasedAt),
+ };
+ }
+
+ if (sustainedDurationMs > 0) {
+ // Track "under pressure" against the same bar isRelieved just used above —
+ // when wasUnderPressure, that's the higher exit threshold, not the base
+ // threshold — otherwise samples sitting in the dead-band (below exitThresholdBytes
+ // but above thresholdBytes) never count as sustained and eviction starves.
+ const activeThresholdBytes = wasUnderPressure ? exitThresholdBytes : thresholdBytes;
+ const validSamples = (history || [])
+ .map((s) => ({
+ at: s.at ?? s.timestamp ?? s.time ?? now,
+ free: s.free ?? (s.total != null && s.used != null ? s.total - s.used : null),
+ }))
+ .filter((s) => s.at <= now && typeof s.free === 'number')
+ .sort((a, b) => a.at - b.at);
+
+ let earliestUnderPressureAt = null;
+ for (let i = validSamples.length - 1; i >= 0; i--) {
+ if (validSamples[i].free < activeThresholdBytes) {
+ earliestUnderPressureAt = validSamples[i].at;
+ } else {
+ break;
+ }
+ }
+
+ const sustainedMs = earliestUnderPressureAt != null ? (now - earliestUnderPressureAt) : 0;
+ if (sustainedMs < sustainedDurationMs) {
+ return {
+ shouldRelease: false,
+ target: null,
+ reason: 'pressure not sustained',
+ sustainedMs,
+ requiredMs: sustainedDurationMs,
+ };
+ }
+ }
+
+ const list = Array.isArray(daemons)
+ ? daemons
+ : Array.from(daemons?.values?.() || []);
+ const eligible = list.filter((d) => (
+ d
+ && d.running !== false
+ && !d.pinned
+ && !d.keepLoaded
+ ));
+
+ if (eligible.length === 0) {
+ return {
+ shouldRelease: false,
+ target: null,
+ reason: 'no eligible daemons to release',
+ };
+ }
+
+ // Least recently used ordering: smallest lastUsedAt first
+ eligible.sort((a, b) => (a.lastUsedAt ?? 0) - (b.lastUsedAt ?? 0));
+ const target = eligible[0];
+
+ return {
+ shouldRelease: true,
+ target,
+ reason: 'host memory pressure',
+ free,
+ thresholdBytes,
+ };
+}
+
+/**
+ * Sweep: stop daemons whose idle window has elapsed, and run pressure-aware pass
+ * to release the least recently used unpinned daemon under sustained host memory pressure.
*
* @param {number} [now]
+ * @param {object} [options]
* @returns {Promise} the names actually stopped
*/
-export async function reapIdleDaemons(now = Date.now()) {
+export async function reapIdleDaemons(now = Date.now(), options = {}) {
const stopped = [];
+
+ // 1. Normal idle timeout pass
for (const [name, entry] of idleDaemons) {
+ // Fail-safe to pinned on a transient read error, matching the pressure-aware
+ // pass below — assuming "not pinned" here would risk stopping a keepLoaded
+ // daemon on a flaky settings read.
+ const isPinned = await Promise.resolve(entry.isPinned?.()).catch(() => true);
+ if (isPinned) continue; // Pinned servers are exempt
+
+ // Already stopped (by the pressure-aware pass, or externally) — skip so this
+ // pass doesn't overwrite its release reason and doesn't preempt the
+ // pressure-aware pass below via the early return once its idle window re-elapses.
+ const isRunning = entry.isRunning
+ ? await Promise.resolve(entry.isRunning()).catch(() => true)
+ : true;
+ if (!isRunning) continue;
+
// Resolved per sweep, so lowering the window in Settings applies to the very
// next beat rather than to the next server restart.
const windowMs = await Promise.resolve(entry.getIdleMs()).catch(() => null);
@@ -335,8 +538,68 @@ export async function reapIdleDaemons(now = Date.now()) {
// Only on success: a failed stop that left the daemon up would otherwise
// retry every beat forever with the clock reset each time.
entry.lastUsedAt = now;
+ entry.releasedAt = now;
+ entry.releaseReason = `released at ${formatReleaseTime(now)} — idle timeout`;
stopped.push(name);
}
+
+ // Release at most one daemon per tick and re-read before the next
+ if (stopped.length > 0) {
+ return stopped;
+ }
+
+ // 2. Pressure-aware pass
+ const memoryStats = options.memoryStats ?? await getMemoryStats().catch(() => null);
+ if (memoryStats) {
+ recordPressureSample({
+ at: now,
+ free: memoryStats.free,
+ used: memoryStats.used,
+ total: memoryStats.total,
+ });
+
+ const daemonList = [];
+ for (const [name, entry] of idleDaemons) {
+ const isPinned = await Promise.resolve(entry.isPinned?.()).catch(() => true);
+ const isRunning = entry.isRunning
+ ? await Promise.resolve(entry.isRunning()).catch(() => false)
+ : true;
+ daemonList.push({
+ name,
+ entry,
+ lastUsedAt: entry.lastUsedAt,
+ pinned: isPinned,
+ running: isRunning,
+ });
+ }
+
+ const policyOptions = { ...options.policyOptions, ...options };
+ const decision = evaluateMemoryPressurePolicy({
+ daemons: daemonList,
+ memoryStats,
+ history: options.history ?? getPressureHistory(),
+ now,
+ lastReleasedAt: getLastPressureReleaseTime(),
+ options: policyOptions,
+ });
+
+ if (decision.shouldRelease && decision.target) {
+ const { target } = decision;
+ const freeMb = Math.round((memoryStats.free || 0) / (1024 * 1024));
+ console.log(`⚠️ Stopping ${target.name} — host memory pressure (free ${freeMb}MB)`);
+ const failed = await Promise.resolve(target.entry.stop()).then(() => null, (err) => err);
+ if (failed) {
+ console.error(`❌ Pressure stop of ${target.name} failed: ${failed.message}`);
+ } else {
+ target.entry.lastUsedAt = now;
+ target.entry.releasedAt = now;
+ target.entry.releaseReason = `released at ${formatReleaseTime(now)} — host memory pressure`;
+ setLastPressureReleaseTime(now);
+ stopped.push(target.name);
+ }
+ }
+ }
+
return stopped;
}
@@ -371,8 +634,11 @@ export function stopIdleReaper() {
reaperTimer = null;
}
-/** Test seam: drop every registration and disarm the timer. */
+/** Test seam: drop every registration, reset pressure state, and disarm the timer. */
export function _resetIdleDaemonsForTests() {
stopIdleReaper();
idleDaemons.clear();
+ pressureHistory = [];
+ lastPressureReleaseAt = null;
}
+
diff --git a/server/lib/managedDaemon.pressure.test.js b/server/lib/managedDaemon.pressure.test.js
new file mode 100644
index 0000000000..edcd69a759
--- /dev/null
+++ b/server/lib/managedDaemon.pressure.test.js
@@ -0,0 +1,410 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import {
+ evaluateMemoryPressurePolicy,
+ formatReleaseTime,
+ registerIdleDaemon,
+ markDaemonUsed,
+ daemonReleaseReason,
+ clearDaemonReleaseReason,
+ reapIdleDaemons,
+ _resetIdleDaemonsForTests,
+ DEFAULT_PRESSURE_THRESHOLD_BYTES,
+ DEFAULT_PRESSURE_CALM_DOWN_MS,
+ DEFAULT_SUSTAINED_PRESSURE_MS,
+ createDaemonWatcher,
+} from './managedDaemon.js';
+
+const MINUTE = 60_000;
+const GB = 1024 * 1024 * 1024;
+
+describe('managedDaemon memory pressure policy', () => {
+ beforeEach(() => {
+ _resetIdleDaemonsForTests();
+ vi.spyOn(console, 'log').mockImplementation(() => {});
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+ afterEach(() => {
+ _resetIdleDaemonsForTests();
+ vi.restoreAllMocks();
+ });
+
+ describe('formatReleaseTime', () => {
+ it('formats a timestamp into HH:MM', () => {
+ const d = new Date(2026, 8, 4, 9, 14);
+ expect(formatReleaseTime(d.getTime())).toBe('09:14');
+ });
+ });
+
+ describe('evaluateMemoryPressurePolicy (pure function)', () => {
+ const now = 1_000_000_000;
+ const sustainedHistory = [
+ { at: now - 40_000, free: 2 * GB },
+ { at: now - 20_000, free: 2 * GB },
+ { at: now, free: 2 * GB },
+ ];
+
+ it('returns shouldRelease: false when memoryStats is missing or invalid', () => {
+ const result = evaluateMemoryPressurePolicy({
+ daemons: [{ name: 'mtplx', lastUsedAt: now - 10_000 }],
+ memoryStats: null,
+ });
+ expect(result.shouldRelease).toBe(false);
+ expect(result.reason).toBe('memory stats unavailable');
+ });
+
+ it('returns shouldRelease: false when memory is above threshold', () => {
+ const result = evaluateMemoryPressurePolicy({
+ daemons: [{ name: 'mtplx', lastUsedAt: now - 10_000 }],
+ memoryStats: { total: 64 * GB, used: 50 * GB, free: 14 * GB },
+ now,
+ });
+ expect(result.shouldRelease).toBe(false);
+ expect(result.reason).toBe('host memory not under pressure');
+ });
+
+ it('stays under pressure when within dead-band hysteresis window', () => {
+ // Threshold is 4GB, dead-band is 2GB, exit threshold is 6GB
+ // When wasUnderPressure is true, 5GB free is still under pressure
+ const resultInDeadBand = evaluateMemoryPressurePolicy({
+ daemons: [{ name: 'mtplx', lastUsedAt: now - 10_000 }],
+ memoryStats: { total: 64 * GB, used: 59 * GB, free: 5 * GB },
+ history: [{ at: now - 40_000, free: 2 * GB }, { at: now, free: 5 * GB }],
+ options: { wasUnderPressure: true, sustainedDurationMs: 0 },
+ now,
+ });
+ expect(resultInDeadBand.shouldRelease).toBe(true);
+
+ // Above threshold + dead-band (7GB >= 6GB), pressure is relieved
+ const resultAboveDeadBand = evaluateMemoryPressurePolicy({
+ daemons: [{ name: 'mtplx', lastUsedAt: now - 10_000 }],
+ memoryStats: { total: 64 * GB, used: 57 * GB, free: 7 * GB },
+ options: { wasUnderPressure: true },
+ now,
+ });
+ expect(resultAboveDeadBand.shouldRelease).toBe(false);
+ expect(resultAboveDeadBand.reason).toBe('host memory not under pressure');
+ });
+
+ it('treats dead-band samples as sustained pressure when wasUnderPressure', () => {
+ // Threshold 4GB, dead-band 2GB, exit threshold 6GB. Free has held at 5GB —
+ // inside the dead-band (below the exit threshold, above the base threshold)
+ // — for well over the default 30s sustained window while wasUnderPressure.
+ const deadBandHistory = [
+ { at: now - 40_000, free: 5 * GB },
+ { at: now - 20_000, free: 5 * GB },
+ { at: now, free: 5 * GB },
+ ];
+ const result = evaluateMemoryPressurePolicy({
+ daemons: [{ name: 'mtplx', lastUsedAt: now - 10_000 }],
+ memoryStats: { total: 64 * GB, used: 59 * GB, free: 5 * GB },
+ history: deadBandHistory,
+ options: { wasUnderPressure: true },
+ now,
+ });
+ expect(result.shouldRelease).toBe(true);
+ expect(result.reason).toBe('host memory pressure');
+ });
+
+ it('returns shouldRelease: false when within the calm-down window', () => {
+ const result = evaluateMemoryPressurePolicy({
+ daemons: [{ name: 'mtplx', lastUsedAt: now - 10_000 }],
+ memoryStats: { total: 64 * GB, used: 62 * GB, free: 2 * GB },
+ history: sustainedHistory,
+ now,
+ lastReleasedAt: now - (DEFAULT_PRESSURE_CALM_DOWN_MS / 2),
+ });
+ expect(result.shouldRelease).toBe(false);
+ expect(result.reason).toBe('in calm-down window');
+ expect(result.remainingCalmDownMs).toBeGreaterThan(0);
+ });
+
+ it('returns shouldRelease: false when pressure is not sustained across the window', () => {
+ // Free dipped below threshold just 5s ago, default sustained requirement is 30s
+ const transientHistory = [
+ { at: now - 60_000, free: 10 * GB },
+ { at: now - 10_000, free: 10 * GB },
+ { at: now - 5_000, free: 2 * GB },
+ ];
+ const result = evaluateMemoryPressurePolicy({
+ daemons: [{ name: 'mtplx', lastUsedAt: now - 10_000 }],
+ memoryStats: { total: 64 * GB, used: 62 * GB, free: 2 * GB },
+ history: transientHistory,
+ now,
+ });
+ expect(result.shouldRelease).toBe(false);
+ expect(result.reason).toBe('pressure not sustained');
+ });
+
+ it('releases the candidate when pressure is sustained and unpinned daemons exist', () => {
+ const daemon = { name: 'mtplx', lastUsedAt: now - 10_000 };
+ const result = evaluateMemoryPressurePolicy({
+ daemons: [daemon],
+ memoryStats: { total: 64 * GB, used: 62 * GB, free: 2 * GB },
+ history: sustainedHistory,
+ now,
+ });
+ expect(result.shouldRelease).toBe(true);
+ expect(result.target).toBe(daemon);
+ expect(result.reason).toBe('host memory pressure');
+ });
+
+ it('releases the least recently used daemon when multiple candidates exist', () => {
+ const daemons = [
+ { name: 'slotstream', lastUsedAt: now - 5_000 },
+ { name: 'mtplx', lastUsedAt: now - 30_000 }, // least recently used
+ { name: 'other', lastUsedAt: now - 10_000 },
+ ];
+ const result = evaluateMemoryPressurePolicy({
+ daemons,
+ memoryStats: { total: 64 * GB, used: 62 * GB, free: 2 * GB },
+ history: sustainedHistory,
+ now,
+ });
+ expect(result.shouldRelease).toBe(true);
+ expect(result.target.name).toBe('mtplx');
+ });
+
+ it('never releases a daemon the user pinned or marked keepLoaded', () => {
+ const daemons = [
+ { name: 'mtplx', lastUsedAt: now - 60_000, pinned: true },
+ { name: 'slotstream', lastUsedAt: now - 50_000, keepLoaded: true },
+ ];
+ const result = evaluateMemoryPressurePolicy({
+ daemons,
+ memoryStats: { total: 64 * GB, used: 62 * GB, free: 2 * GB },
+ history: sustainedHistory,
+ now,
+ });
+ expect(result.shouldRelease).toBe(false);
+ expect(result.reason).toBe('no eligible daemons to release');
+ });
+
+ it('skips pinned daemon and releases the next least recently used unpinned daemon', () => {
+ const daemons = [
+ { name: 'mtplx', lastUsedAt: now - 60_000, pinned: true },
+ { name: 'slotstream', lastUsedAt: now - 20_000, pinned: false },
+ ];
+ const result = evaluateMemoryPressurePolicy({
+ daemons,
+ memoryStats: { total: 64 * GB, used: 62 * GB, free: 2 * GB },
+ history: sustainedHistory,
+ now,
+ });
+ expect(result.shouldRelease).toBe(true);
+ expect(result.target.name).toBe('slotstream');
+ });
+
+ it('skips daemons that are not currently running', () => {
+ const daemons = [
+ { name: 'mtplx', lastUsedAt: now - 60_000, running: false },
+ { name: 'slotstream', lastUsedAt: now - 20_000, running: true },
+ ];
+ const result = evaluateMemoryPressurePolicy({
+ daemons,
+ memoryStats: { total: 64 * GB, used: 62 * GB, free: 2 * GB },
+ history: sustainedHistory,
+ now,
+ });
+ expect(result.shouldRelease).toBe(true);
+ expect(result.target.name).toBe('slotstream');
+ });
+
+ it('honors sustainedDurationMs: 0 for instant evaluation', () => {
+ const result = evaluateMemoryPressurePolicy({
+ daemons: [{ name: 'mtplx', lastUsedAt: now - 1000 }],
+ memoryStats: { total: 64 * GB, used: 62 * GB, free: 2 * GB },
+ history: [],
+ now,
+ options: { sustainedDurationMs: 0 },
+ });
+ expect(result.shouldRelease).toBe(true);
+ expect(result.target.name).toBe('mtplx');
+ });
+ });
+
+ describe('reapIdleDaemons pressure-aware integration', () => {
+ it('stops the least recently used daemon early under sustained pressure', async () => {
+ const stopMtplx = vi.fn().mockResolvedValue(undefined);
+ const stopSlotstream = vi.fn().mockResolvedValue(undefined);
+
+ // Window is 60 minutes, so neither would be stopped by normal idle timer
+ registerIdleDaemon({ name: 'daemon-mtplx', getIdleMs: () => 60 * MINUTE, stop: stopMtplx });
+ registerIdleDaemon({ name: 'daemon-slotstream', getIdleMs: () => 60 * MINUTE, stop: stopSlotstream });
+
+ const now = Date.now();
+ // mark slotstream used more recently
+ markDaemonUsed('daemon-slotstream');
+
+ const stopped = await reapIdleDaemons(now, {
+ memoryStats: { total: 64 * GB, used: 62 * GB, free: 2 * GB },
+ history: [
+ { at: now - 40_000, free: 2 * GB },
+ { at: now, free: 2 * GB },
+ ],
+ sustainedDurationMs: 30_000,
+ });
+
+ expect(stopped).toEqual(['daemon-mtplx']);
+ expect(stopMtplx).toHaveBeenCalledTimes(1);
+ expect(stopSlotstream).not.toHaveBeenCalled();
+
+ // Reason recorded on the daemon entry
+ const timeStr = formatReleaseTime(now);
+ expect(daemonReleaseReason('daemon-mtplx')).toBe(`released at ${timeStr} — host memory pressure`);
+ });
+
+ it('releases at most one daemon per tick and re-reads before the next', async () => {
+ const stopA = vi.fn().mockResolvedValue(undefined);
+ const stopB = vi.fn().mockResolvedValue(undefined);
+
+ registerIdleDaemon({ name: 'daemon-a', getIdleMs: () => 60 * MINUTE, stop: stopA });
+ registerIdleDaemon({ name: 'daemon-b', getIdleMs: () => 60 * MINUTE, stop: stopB });
+
+ const now = Date.now();
+ const stopped = await reapIdleDaemons(now, {
+ memoryStats: { total: 64 * GB, used: 62 * GB, free: 2 * GB },
+ history: [{ at: now - 35_000, free: 2 * GB }, { at: now, free: 2 * GB }],
+ sustainedDurationMs: 30_000,
+ });
+
+ expect(stopped.length).toBe(1);
+ expect(stopA.mock.calls.length + stopB.mock.calls.length).toBe(1);
+ });
+
+ it('never stops a pinned daemon under memory pressure', async () => {
+ const stop = vi.fn().mockResolvedValue(undefined);
+ registerIdleDaemon({
+ name: 'daemon-pinned',
+ getIdleMs: () => 60 * MINUTE,
+ isPinned: () => true,
+ stop,
+ });
+
+ const now = Date.now();
+ const stopped = await reapIdleDaemons(now, {
+ memoryStats: { total: 64 * GB, used: 62 * GB, free: 2 * GB },
+ history: [{ at: now - 40_000, free: 2 * GB }, { at: now, free: 2 * GB }],
+ sustainedDurationMs: 30_000,
+ });
+
+ expect(stopped).toEqual([]);
+ expect(stop).not.toHaveBeenCalled();
+ expect(daemonReleaseReason('daemon-pinned')).toBeNull();
+ });
+
+ it('fail-safes to pinned when isPinned throws or rejects', async () => {
+ const stop = vi.fn().mockResolvedValue(undefined);
+ registerIdleDaemon({
+ name: 'daemon-flaky-pin',
+ getIdleMs: () => 60 * MINUTE,
+ isPinned: () => Promise.reject(new Error('transient read failure')),
+ stop,
+ });
+
+ const now = Date.now();
+ const stopped = await reapIdleDaemons(now, {
+ memoryStats: { total: 64 * GB, used: 62 * GB, free: 2 * GB },
+ history: [{ at: now - 35_000, free: 2 * GB }, { at: now, free: 2 * GB }],
+ sustainedDurationMs: 30_000,
+ });
+
+ expect(stopped).toEqual([]);
+ expect(stop).not.toHaveBeenCalled();
+ });
+
+ it('idle-timeout pass skips an already-stopped daemon instead of re-stopping it', async () => {
+ let running = true;
+ const stop = vi.fn().mockImplementation(() => {
+ running = false;
+ return Promise.resolve();
+ });
+ registerIdleDaemon({
+ name: 'daemon-already-stopped',
+ getIdleMs: () => 5 * MINUTE,
+ isRunning: () => running,
+ stop,
+ });
+
+ const now1 = Date.now();
+ // Idle window (5m) hasn't elapsed since registration, so this falls through
+ // to the pressure-aware pass, which stops it and records why.
+ const stoppedByPressure = await reapIdleDaemons(now1, {
+ memoryStats: { total: 64 * GB, used: 62 * GB, free: 2 * GB },
+ history: [{ at: now1 - 40_000, free: 2 * GB }, { at: now1, free: 2 * GB }],
+ sustainedDurationMs: 30_000,
+ });
+ expect(stoppedByPressure).toEqual(['daemon-already-stopped']);
+ expect(stop).toHaveBeenCalledTimes(1);
+ const pressureReason = daemonReleaseReason('daemon-already-stopped');
+ expect(pressureReason).toContain('host memory pressure');
+
+ // The pressure stop reset lastUsedAt to now1 — advance past the idle
+ // window without the daemon ever restarting.
+ const now2 = now1 + 6 * MINUTE;
+ const stoppedByIdle = await reapIdleDaemons(now2, { memoryStats: false });
+
+ expect(stoppedByIdle).toEqual([]);
+ expect(stop).toHaveBeenCalledTimes(1);
+ expect(daemonReleaseReason('daemon-already-stopped')).toBe(pressureReason);
+ });
+
+ it('clears releaseReason when markDaemonUsed is called upon server restart', async () => {
+ const stop = vi.fn().mockResolvedValue(undefined);
+ registerIdleDaemon({ name: 'daemon-a', getIdleMs: () => 60 * MINUTE, stop });
+
+ const now = Date.now();
+ await reapIdleDaemons(now, {
+ memoryStats: { total: 64 * GB, used: 62 * GB, free: 2 * GB },
+ history: [{ at: now - 40_000, free: 2 * GB }, { at: now, free: 2 * GB }],
+ sustainedDurationMs: 30_000,
+ });
+
+ expect(daemonReleaseReason('daemon-a')).toMatch(/host memory pressure/);
+
+ // User or lazy start triggers markDaemonUsed
+ markDaemonUsed('daemon-a');
+ expect(daemonReleaseReason('daemon-a')).toBeNull();
+ });
+
+ it('reports releaseReason in watcher getStatusBase when stopped, and null when running', async () => {
+ let pm2Status = { status: 'stopped', pid: null, args: [] };
+ const watcher = createDaemonWatcher({
+ appName: 'daemon-test',
+ defaultPort: 8000,
+ endpointFor: () => 'http://127.0.0.1:8000/v1',
+ parseConfigFromArgs: () => ({}),
+ probe: vi.fn().mockResolvedValue(false),
+ isPortInUse: vi.fn().mockResolvedValue(false),
+ sleep: vi.fn(),
+ getConfig: () => null,
+ setConfig: vi.fn(),
+ getLastExitError: () => null,
+ getAppStatus: vi.fn(async () => pm2Status),
+ getSavedProcessNames: vi.fn(async () => []),
+ execPm2: vi.fn(async () => ({ stdout: '', stderr: '' })),
+ getPortReleaseTimeoutMs: () => 5000,
+ });
+
+ registerIdleDaemon({
+ name: 'daemon-test',
+ getIdleMs: () => 60 * MINUTE,
+ stop: vi.fn(),
+ });
+
+ const now = Date.now();
+ await reapIdleDaemons(now, {
+ memoryStats: { total: 64 * GB, used: 62 * GB, free: 2 * GB },
+ history: [{ at: now - 40_000, free: 2 * GB }, { at: now, free: 2 * GB }],
+ sustainedDurationMs: 30_000,
+ });
+
+ const stoppedStatus = await watcher.getStatusBase({ installed: true });
+ expect(stoppedStatus.releaseReason).toMatch(/host memory pressure/);
+
+ // Now simulate server running online
+ pm2Status = { status: 'online', pid: 1234, args: [] };
+ const onlineStatus = await watcher.getStatusBase({ installed: true });
+ expect(onlineStatus.releaseReason).toBeNull();
+ });
+ });
+});
diff --git a/server/lib/validation.js b/server/lib/validation.js
index 2abe2af928..4e6d6d8c7e 100644
--- a/server/lib/validation.js
+++ b/server/lib/validation.js
@@ -1691,9 +1691,15 @@ export const localLlmSettingsSchema = z.object({
// release the model, which is what every install did before this setting
// existed and stays the default. Capped at a day: a longer window is
// indistinguishable from "never" and is far likelier a units mix-up.
- llama: z.object({ idleMinutes: z.number().int().min(0).max(1440).optional() }).strict().optional(),
+ llama: z.object({
+ idleMinutes: z.number().int().min(0).max(1440).optional(),
+ keepLoaded: z.boolean().optional(),
+ pinned: z.boolean().optional(),
+ }).strict().optional(),
mtplx: z.object({
idleMinutes: z.number().int().min(0).max(1440).optional(),
+ keepLoaded: z.boolean().optional(),
+ pinned: z.boolean().optional(),
// The launch line a lazy start replays. MTPLX has no Start button any more —
// the first request that needs it brings it up — so the checkpoint and port
// the user chose have to outlive the process, or an on-demand start would
@@ -1706,6 +1712,8 @@ export const localLlmSettingsSchema = z.object({
}).strict().optional(),
slotstream: z.object({
idleMinutes: z.number().int().min(0).max(1440).optional(),
+ keepLoaded: z.boolean().optional(),
+ pinned: z.boolean().optional(),
launch: z.object({
model: z.string().trim().max(300).nullable().optional(),
port: z.number().int().min(1).max(65535).optional(),
diff --git a/server/services/llamaServerManager.js b/server/services/llamaServerManager.js
index e72f52d24f..f00186fc40 100644
--- a/server/services/llamaServerManager.js
+++ b/server/services/llamaServerManager.js
@@ -61,6 +61,7 @@ const SLEEP_IDLE_FLAG = '--sleep-idle-seconds';
const sleepIdleSupport = new Map();
// See `configuredSleepIdleMinutes`. Only `_resetLlamaServerStateForTests` writes it.
let sleepIdleMinutesOverride = null;
+let sleepIdleKeepLoadedOverride = null;
// Which package manager PortOS drives for llama.cpp. Only the test seam writes
// it, so both the Homebrew and the winget path stay coverable from either OS.
let platformOverride = null;
@@ -335,6 +336,12 @@ async function configuredSleepIdleMinutes() {
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
}
+async function configuredSleepIdleKeepLoaded() {
+ if (sleepIdleKeepLoadedOverride !== null) return sleepIdleKeepLoadedOverride;
+ const settings = await import('./settings.js').then((m) => m.getSettings()).catch(() => null);
+ return Boolean(settings?.localLlm?.llama?.keepLoaded ?? settings?.localLlm?.llama?.pinned);
+}
+
/**
* Fails the start request when a GGUF the launch line names is not on disk.
*/
@@ -476,6 +483,7 @@ export async function getLlamaServerStatus() {
// process actually got is `config.sleepIdleMinutes` — they differ until the
// next start, because this is a launch flag.
idleMinutes: await configuredSleepIdleMinutes(),
+ keepLoaded: await configuredSleepIdleKeepLoaded(),
};
}
@@ -661,14 +669,15 @@ export async function startLlamaServer(options = {}) {
// feature existed — never a rejected launch. `effectiveIdleMinutes` is what
// actually reached the process, so the status card reports the truth rather
// than the request.
- const effectiveIdleMinutes = requestedIdleMinutes > 0 && await supportsSleepIdle(binaryPath)
+ const isPinned = await configuredSleepIdleKeepLoaded();
+ const effectiveIdleMinutes = !isPinned && requestedIdleMinutes > 0 && await supportsSleepIdle(binaryPath)
? requestedIdleMinutes
: 0;
if (effectiveIdleMinutes > 0) args.push(SLEEP_IDLE_FLAG, String(effectiveIdleMinutes * 60));
lastExitError = null;
daemon.resetLogs();
- if (requestedIdleMinutes > 0 && effectiveIdleMinutes === 0) {
+ if (!isPinned && requestedIdleMinutes > 0 && effectiveIdleMinutes === 0) {
appendLog(`This llama-server build has no ${SLEEP_IDLE_FLAG} — the model stays resident while idle`);
}
if (droppedSpecTypes.length > 0) {
@@ -1559,11 +1568,13 @@ export function _resetLlamaServerStateForTests({
relaunchPollDelay,
pm2ReadRetryDelay,
sleepIdleMinutes = 0,
+ sleepIdleKeepLoaded = null,
platform = null,
} = {}) {
sleepIdleSupport.clear();
// Pinned rather than read from disk — see `configuredSleepIdleMinutes`.
sleepIdleMinutesOverride = sleepIdleMinutes;
+ sleepIdleKeepLoadedOverride = sleepIdleKeepLoaded;
// `null` = use the real host platform, which is what every suite that is not
// specifically exercising the other package manager's path wants.
platformOverride = platform;
@@ -1578,3 +1589,7 @@ export function _resetLlamaServerStateForTests({
relaunchPollDelayMs = Number.isFinite(relaunchPollDelay) ? relaunchPollDelay : RELAUNCH_POLL_DELAY_MS;
pm2ReadRetryDelayMs = Number.isFinite(pm2ReadRetryDelay) ? pm2ReadRetryDelay : PM2_READ_RETRY_DELAY_MS;
}
+
+export function _setLlamaKeepLoadedOverrideForTests(val) {
+ sleepIdleKeepLoadedOverride = val;
+}
diff --git a/server/services/llamaServerManager.test.js b/server/services/llamaServerManager.test.js
index 730f9da3b1..2aa128a1ad 100644
--- a/server/services/llamaServerManager.test.js
+++ b/server/services/llamaServerManager.test.js
@@ -11,6 +11,7 @@ import {
getLlamaServerUpdateStatus,
upgradeLlamaServer,
_resetLlamaServerStateForTests,
+ _setLlamaKeepLoadedOverrideForTests,
LLAMA_APP,
} from './llamaServerManager.js';
import * as processEnv from '../lib/processEnv.js';
@@ -1527,6 +1528,15 @@ describe('llamaServerManager', () => {
expect(startArgs()).not.toContain('--sleep-idle-seconds');
});
+ it('leaves the flag off when keepLoaded is configured', async () => {
+ mockHelp('--sleep-idle-seconds SECONDS number of seconds of idleness');
+ _setLlamaKeepLoadedOverrideForTests(true);
+
+ await startLlamaServer({ model: modelPath, draftModel: null, specType: '', port: 8080, sleepIdleMinutes: 30 });
+
+ expect(startArgs()).not.toContain('--sleep-idle-seconds');
+ });
+
// The compatibility guarantee: an install on an older llama.cpp must keep
// starting. Emitting an unknown flag would make the daemon exit immediately.
it('omits the flag on a build that does not advertise it, rather than failing the start', async () => {
diff --git a/server/services/mtplxServerManager.js b/server/services/mtplxServerManager.js
index f1318f717b..67bcdb1215 100644
--- a/server/services/mtplxServerManager.js
+++ b/server/services/mtplxServerManager.js
@@ -364,6 +364,7 @@ export async function getMtplxServerStatus() {
// another process's launch line.
tuningFlags: base.managed === true ? launchArgs('mtplx', currentConfig?.tuning) : [],
idleMinutes: await configuredIdleMinutes(),
+ keepLoaded: await configuredKeepLoaded(),
// What a lazy start will launch on, so the card's fields show the saved
// choice rather than resetting to "Auto" on every page load.
launch: await savedLaunchConfig(),
@@ -821,9 +822,11 @@ export function _resetMtplxServerStateForTests({
relaunchReadyTimeout,
relaunchPoll,
idleMinutes = 0,
+ keepLoaded = null,
logFiles,
} = {}) {
idleMinutesOverride = idleMinutes;
+ keepLoadedOverride = keepLoaded;
mtplxLogFiles = logFiles ? {
stdout: logFiles.stdout || DEFAULT_MTPLX_LOG_FILES.stdout,
stderr: logFiles.stderr || DEFAULT_MTPLX_LOG_FILES.stderr,
@@ -839,6 +842,11 @@ export function _resetMtplxServerStateForTests({
relaunchPollMs = Number.isFinite(relaunchPoll) ? relaunchPoll : 1000;
}
+// Test hook for pinning
+export function _setMtplxKeepLoadedOverrideForTests(val) {
+ keepLoadedOverride = val;
+}
+
// =============================================================================
// IDLE STOP + LAZY START
// =============================================================================
@@ -865,6 +873,7 @@ export function _resetMtplxServerStateForTests({
const readSettings = () => import('./settings.js').then((m) => m.getSettings()).catch(() => null);
// See `configuredIdleMinutes`. Only `_resetMtplxServerStateForTests` writes it.
let idleMinutesOverride = null;
+let keepLoadedOverride = null;
async function configuredIdleMinutes() {
// Test seam, same reason as `llamaServerManager`'s: a suite must not depend on
@@ -875,12 +884,20 @@ async function configuredIdleMinutes() {
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
}
+async function configuredKeepLoaded() {
+ if (keepLoadedOverride !== null) return keepLoadedOverride;
+ const settings = await readSettings();
+ return Boolean(settings?.localLlm?.mtplx?.keepLoaded ?? settings?.localLlm?.mtplx?.pinned);
+}
+
// Registered at module load so the reaper knows about MTPLX regardless of which
// call path touches this module first. Registration itself starts nothing and
// reads no settings — the window is resolved per sweep, inside `getIdleMs`.
registerIdleDaemon({
name: MTPLX_APP,
getIdleMs: async () => idleWindowMs(await configuredIdleMinutes()),
+ isPinned: async () => configuredKeepLoaded(),
+ isRunning: async () => Boolean((await getAppStatusStrict(MTPLX_APP))?.status === 'online'),
stop: () => stopMtplxServer(),
});
diff --git a/server/services/slotstreamServerManager.js b/server/services/slotstreamServerManager.js
index 67ae8316b1..30fd2cd76e 100644
--- a/server/services/slotstreamServerManager.js
+++ b/server/services/slotstreamServerManager.js
@@ -167,6 +167,7 @@ export async function getSlotstreamServerStatus() {
supported,
unsupportedReason: supported ? null : SLOTSTREAM_UNSUPPORTED_REASON,
idleMinutes: await configuredIdleMinutes(),
+ keepLoaded: await configuredKeepLoaded(),
launch: saved,
memoryPlan,
cachedModels: (cache.models || []).map((m) => m?.id).filter(Boolean),
@@ -405,9 +406,11 @@ export function _resetSlotstreamServerStateForTests({
relaunchReadyTimeout,
relaunchPoll,
idleMinutes = 0,
+ keepLoaded = null,
logFiles,
} = {}) {
idleMinutesOverride = idleMinutes;
+ keepLoadedOverride = keepLoaded;
slotstreamLogFiles = logFiles ? {
stdout: logFiles.stdout || DEFAULT_SLOTSTREAM_LOG_FILES.stdout,
stderr: logFiles.stderr || DEFAULT_SLOTSTREAM_LOG_FILES.stderr,
@@ -421,8 +424,14 @@ export function _resetSlotstreamServerStateForTests({
relaunchPollMs = Number.isFinite(relaunchPoll) ? relaunchPoll : 1000;
}
+// Test hook for pinning
+export function _setSlotstreamKeepLoadedOverrideForTests(val) {
+ keepLoadedOverride = val;
+}
+
const readSettings = () => import('./settings.js').then((m) => m.getSettings()).catch(() => null);
let idleMinutesOverride = null;
+let keepLoadedOverride = null;
async function configuredIdleMinutes() {
if (idleMinutesOverride !== null) return idleMinutesOverride;
@@ -431,9 +440,17 @@ async function configuredIdleMinutes() {
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
}
+async function configuredKeepLoaded() {
+ if (keepLoadedOverride !== null) return keepLoadedOverride;
+ const settings = await readSettings();
+ return Boolean(settings?.localLlm?.slotstream?.keepLoaded ?? settings?.localLlm?.slotstream?.pinned);
+}
+
registerIdleDaemon({
name: SLOTSTREAM_APP,
getIdleMs: async () => idleWindowMs(await configuredIdleMinutes()),
+ isPinned: async () => configuredKeepLoaded(),
+ isRunning: async () => Boolean((await getAppStatusStrict(SLOTSTREAM_APP))?.status === 'online'),
stop: () => stopSlotstreamServer(),
});