@otto-code/brain 0.8.10 → 0.8.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/commands/bench.js +2 -2
  2. package/dist/commands/calibrate.js +11 -2
  3. package/dist/commands/catalog.d.ts +1 -0
  4. package/dist/commands/catalog.js +1 -0
  5. package/dist/commands/pull.d.ts +1 -0
  6. package/dist/commands/pull.js +12 -3
  7. package/dist/commands/search.d.ts +1 -0
  8. package/dist/commands/search.js +12 -2
  9. package/dist/config/index.d.ts +2 -2
  10. package/dist/config/index.js +2 -2
  11. package/dist/config/profile-edit.d.ts +88 -1
  12. package/dist/config/profile-edit.js +294 -43
  13. package/dist/config/profiles.d.ts +19 -3
  14. package/dist/config/profiles.js +52 -4
  15. package/dist/config/schema.d.ts +616 -0
  16. package/dist/config/schema.js +65 -3
  17. package/dist/config/store.js +7 -4
  18. package/dist/gguf.d.ts +7 -0
  19. package/dist/gguf.js +15 -2
  20. package/dist/models/download.d.ts +1 -1
  21. package/dist/models/download.js +2 -2
  22. package/dist/models/enrich.d.ts +6 -0
  23. package/dist/models/enrich.js +27 -1
  24. package/dist/models/index.d.ts +1 -1
  25. package/dist/models/index.js +4 -3
  26. package/dist/ops/archive.d.ts +14 -1
  27. package/dist/ops/archive.js +9 -5
  28. package/dist/ops/calibrate.d.ts +38 -3
  29. package/dist/ops/calibrate.js +68 -19
  30. package/dist/ops/report.js +51 -1
  31. package/dist/ops/results.d.ts +77 -11
  32. package/dist/ops/results.js +84 -14
  33. package/dist/ops/sweep.d.ts +38 -1
  34. package/dist/ops/sweep.js +61 -10
  35. package/dist/runtime/args.d.ts +15 -2
  36. package/dist/runtime/args.js +60 -5
  37. package/dist/runtime/managed.js +2 -2
  38. package/dist/service/activity.d.ts +19 -0
  39. package/dist/service/activity.js +47 -4
  40. package/dist/service/host-api.d.ts +28 -4
  41. package/dist/service/host-api.js +109 -28
  42. package/dist/service/log-format.d.ts +18 -0
  43. package/dist/service/log-format.js +32 -0
  44. package/dist/service/process-pool.d.ts +45 -0
  45. package/dist/service/process-pool.js +271 -0
  46. package/dist/service/router.d.ts +74 -3
  47. package/dist/service/router.js +277 -51
  48. package/dist/service/run-log.d.ts +6 -1
  49. package/dist/service/run-log.js +46 -4
  50. package/dist/service/scheduler.d.ts +250 -31
  51. package/dist/service/scheduler.js +408 -63
  52. package/dist/service/serve.d.ts +4 -0
  53. package/dist/service/serve.js +376 -142
  54. package/dist/service/status-events.d.ts +14 -1
  55. package/dist/service/status-events.js +112 -12
  56. package/dist/service/supervisor.d.ts +9 -7
  57. package/dist/service/supervisor.js +37 -12
  58. package/dist/sysmon.d.ts +15 -0
  59. package/dist/sysmon.js +56 -9
  60. package/dist/tui/app.d.ts +8 -2
  61. package/dist/tui/app.js +83 -26
  62. package/dist/types.d.ts +18 -0
  63. package/dist/vram.d.ts +37 -0
  64. package/dist/vram.js +57 -18
  65. package/package.json +1 -1
@@ -13,7 +13,7 @@ import { execFile, spawn } from "node:child_process";
13
13
  import { randomUUID } from "node:crypto";
14
14
  import http from "node:http";
15
15
  import https from "node:https";
16
- import { getCalibration, forModel, loadPersistedConfig, loadProfilesStore, put, putCalibration, saveBrainConfig, saveProfilesStore, } from "../config/index.js";
16
+ import { getCalibrationForBudget, forModel, loadPersistedConfig, loadProfilesStore, put, putCalibration, saveBrainConfig, saveProfilesStore, } from "../config/index.js";
17
17
  import { resolveBrainPaths } from "../config/paths.js";
18
18
  import { query as queryGpu } from "../gpu.js";
19
19
  import { managedModelsDir, pickAutoModel, pickModel, scanModels } from "../models/index.js";
@@ -26,16 +26,19 @@ import * as archive from "../ops/archive.js";
26
26
  import { calibrate } from "../ops/calibrate.js";
27
27
  import { sweep } from "../ops/sweep.js";
28
28
  import * as bench from "../bench/index.js";
29
- import { createCpuSampler, sample as sampleSystem } from "../sysmon.js";
29
+ import { createCpuSampler, sample as sampleSystem, slots as sampleSlots } from "../sysmon.js";
30
30
  import { createHostApi } from "./host-api.js";
31
31
  import { errorMessage } from "./http-util.js";
32
- import { createRouter, Telemetry } from "./router.js";
33
- import { BrainStatusPublisher } from "./status-events.js";
32
+ import { createRouter, createSlotEraser, Telemetry } from "./router.js";
33
+ import { Scheduler } from "./scheduler.js";
34
+ import { ModelProcessPool } from "./process-pool.js";
35
+ import { BrainLogPublisher, BrainStatusPublisher } from "./status-events.js";
34
36
  import { Supervisor } from "./supervisor.js";
35
37
  import * as tailscale from "./tailscale.js";
36
38
  import { CertManager, resolveTlsOptions } from "./tls.js";
37
39
  import { removePidFile, writePidFile } from "./pid-lock.js";
38
40
  import { createBrainRunLog } from "./run-log.js";
41
+ import { formatBrainLog } from "./log-format.js";
39
42
  /** The effective config with secrets masked, for the `/__host/config` read. */
40
43
  function redactConfig(config) {
41
44
  return {
@@ -62,16 +65,77 @@ function collectEvals() {
62
65
  }
63
66
  }
64
67
  const REMOTE_JOB_RETENTION_MS = 5 * 60000;
68
+ /** Model pulls only write the model store, so independent entries can transfer together. */
69
+ export function canRunAlongsideModelPull(kind) {
70
+ return kind === "pull" || kind === "runtime-remove";
71
+ }
72
+ export function componentOnlyArgs(args, components) {
73
+ const separator = args.lastIndexOf("--");
74
+ const beforeTarget = separator === -1 ? args : args.slice(0, separator);
75
+ const target = separator === -1 ? [] : args.slice(separator);
76
+ const base = [];
77
+ for (let index = 0; index < beforeTarget.length; index += 1) {
78
+ const arg = beforeTarget[index];
79
+ if (arg === "--primary-only")
80
+ continue;
81
+ if (arg === "--component") {
82
+ index += 1;
83
+ continue;
84
+ }
85
+ base.push(arg);
86
+ }
87
+ return [
88
+ ...base,
89
+ "--components-only",
90
+ ...components.flatMap((component) => ["--component", component]),
91
+ ...target,
92
+ ];
93
+ }
65
94
  class ServiceJobRunner {
66
- constructor(onPullCompleted, runResidentJob) {
95
+ constructor(onPullCompleted, runResidentJob, scheduler, resolveTarget, log) {
67
96
  this.onPullCompleted = onPullCompleted;
68
97
  this.runResidentJob = runResidentJob;
98
+ this.scheduler = scheduler;
99
+ this.resolveTarget = resolveTarget;
100
+ this.log = log;
69
101
  this.jobs = new Map();
70
102
  }
71
- start(kind, target, args) {
103
+ area(kind) {
104
+ return kind === "pull" || kind === "runtime-install" || kind === "runtime-remove"
105
+ ? "library"
106
+ : "model";
107
+ }
108
+ start(kind, target, args, pull) {
109
+ if (kind === "pull" && pull) {
110
+ const existing = [...this.jobs.values()].find((job) => job.kind === "pull" && job.status === "running" && job.pull?.entryKey === pull.entryKey);
111
+ if (existing?.pull) {
112
+ const components = pull.components.filter((component) => !existing.pull.components.has(component));
113
+ if (components.length > 0) {
114
+ for (const component of components)
115
+ existing.pull.components.add(component);
116
+ existing.pull.queue.push({ args: componentOnlyArgs(args, components), components });
117
+ existing.message = `Queued ${components.join(", ")}…`;
118
+ this.log("library", `job ${existing.id} queued bundle components: ${components.join(", ")}`);
119
+ }
120
+ return this.publicJob(existing);
121
+ }
122
+ }
123
+ const isResidentOperation = kind === "calibrate" || kind === "sweep" || kind === "bench";
72
124
  const running = [...this.jobs.values()].find((job) => job.status === "running");
73
- if (running)
74
- throw new Error(`Another operation is already running (${running.label}).`);
125
+ const activeNonDownload = [...this.jobs.values()].find((job) => job.status === "running" && job.kind !== "pull");
126
+ // Resident operations join Scheduler instead of rejecting one another. It
127
+ // owns their turn order with API requests and performs every model swap.
128
+ const conflict = isResidentOperation
129
+ ? undefined
130
+ : canRunAlongsideModelPull(kind)
131
+ ? activeNonDownload
132
+ : running;
133
+ if (conflict)
134
+ throw new Error(`Another operation is already running (${conflict.label}).`);
135
+ const residentTarget = isResidentOperation ? this.resolveTarget(target) : null;
136
+ if (isResidentOperation && !residentTarget) {
137
+ throw new Error("No installed model is available for this operation.");
138
+ }
75
139
  const job = {
76
140
  id: `brainjob_${randomUUID()}`,
77
141
  kind,
@@ -89,21 +153,43 @@ class ServiceJobRunner {
89
153
  finishedAt: null,
90
154
  child: null,
91
155
  controller: null,
156
+ ...(kind === "pull" && pull
157
+ ? {
158
+ pull: {
159
+ entryKey: pull.entryKey,
160
+ components: new Set(pull.components),
161
+ queue: [],
162
+ },
163
+ }
164
+ : {}),
92
165
  };
93
166
  this.jobs.set(job.id, job);
167
+ this.log(this.area(job.kind), `job ${job.id} started: ${job.label}`);
94
168
  if (kind === "calibrate" || kind === "sweep" || kind === "bench") {
169
+ const model = residentTarget;
95
170
  const controller = new AbortController();
96
171
  job.controller = controller;
97
- void this.runResidentJob(kind, target, {
98
- message: (value) => {
99
- if (job.status === "running")
100
- job.message = value.slice(-1000);
101
- },
102
- percent: (value) => {
103
- if (job.status === "running")
104
- job.percent = value;
172
+ job.queuePosition = this.scheduler.stats().queued + 1;
173
+ job.message = `Queued for ${model.displayName}`;
174
+ void this.scheduler
175
+ .submit(model, (supervisor) => controller.signal.aborted
176
+ ? Promise.reject(new Error("Operation canceled."))
177
+ : this.runResidentJob(supervisor, kind, model.id, {
178
+ message: (value) => {
179
+ if (job.status === "running")
180
+ job.message = value.slice(-1000);
181
+ },
182
+ percent: (value) => {
183
+ if (job.status === "running")
184
+ job.percent = value;
185
+ },
186
+ }, controller.signal), {
187
+ kind: kind === "bench" ? "benchmark" : kind,
188
+ onStart: () => {
189
+ job.queuePosition = null;
190
+ job.message = `${job.label} started`;
105
191
  },
106
- }, controller.signal)
192
+ })
107
193
  .then(() => this.finish(job, "succeeded", null))
108
194
  .catch((error) => this.finish(job, controller.signal.aborted ? "canceled" : "failed", errorMessage(error)));
109
195
  return this.publicJob(job);
@@ -113,20 +199,36 @@ class ServiceJobRunner {
113
199
  const entry = process.argv[1];
114
200
  if (!entry)
115
201
  throw new Error("The brain service has no CLI entry point.");
202
+ this.startChild(job, args, entry);
203
+ return this.publicJob(job);
204
+ }
205
+ startChild(job, args, entry) {
116
206
  const child = spawn(process.execPath, [entry, ...args], {
117
207
  cwd: process.cwd(),
118
208
  env: process.env,
119
- stdio: ["ignore", "ignore", "pipe"],
209
+ stdio: ["ignore", "pipe", "pipe"],
120
210
  windowsHide: true,
121
211
  });
122
212
  job.child = child;
213
+ this.log(this.area(job.kind), `job ${job.id} spawned pid ${child.pid ?? "unknown"}: ${args.join(" ")}`);
214
+ child.stdout?.setEncoding("utf8");
123
215
  child.stderr?.setEncoding("utf8");
124
- child.stderr?.on("data", (chunk) => this.ingestOutput(job, chunk));
216
+ child.stdout?.on("data", (chunk) => this.ingestOutput(job, "stdout", chunk));
217
+ child.stderr?.on("data", (chunk) => this.ingestOutput(job, "stderr", chunk));
125
218
  child.once("error", (error) => this.finish(job, "failed", error.message));
126
219
  child.once("close", (code) => {
127
220
  if (job.status !== "running")
128
221
  return;
129
- if (code === 0 && kind === "pull") {
222
+ job.child = null;
223
+ if (code === 0 && job.kind === "pull" && job.pull) {
224
+ const next = job.pull.queue.shift();
225
+ if (next) {
226
+ job.message = `Queued ${next.components.join(", ")}…`;
227
+ this.startChild(job, next.args, entry);
228
+ return;
229
+ }
230
+ }
231
+ if (code === 0 && job.kind === "pull") {
130
232
  try {
131
233
  // Downloads happen in a child process, but inventory is served from
132
234
  // this process's in-memory scan. Reconcile before reporting success
@@ -140,9 +242,8 @@ class ServiceJobRunner {
140
242
  }
141
243
  this.finish(job, code === 0 ? "succeeded" : "failed", code === 0 ? null : (job.message ?? `Exited with code ${code}.`));
142
244
  });
143
- return this.publicJob(job);
144
245
  }
145
- async query(args) {
246
+ async query(args, area = "library") {
146
247
  const entry = process.argv[1];
147
248
  if (!entry)
148
249
  throw new Error("The brain service has no CLI entry point.");
@@ -157,10 +258,21 @@ class ServiceJobRunner {
157
258
  let err = "";
158
259
  child.stdout?.setEncoding("utf8");
159
260
  child.stderr?.setEncoding("utf8");
160
- child.stdout?.on("data", (chunk) => (out += chunk));
161
- child.stderr?.on("data", (chunk) => (err += chunk));
162
- child.once("error", reject);
261
+ this.log(area, `query started: ${args.join(" ")}`);
262
+ child.stdout?.on("data", (chunk) => {
263
+ out += chunk;
264
+ this.logOutput(area, "query stdout", chunk);
265
+ });
266
+ child.stderr?.on("data", (chunk) => {
267
+ err += chunk;
268
+ this.logOutput(area, "query stderr", chunk);
269
+ });
270
+ child.once("error", (error) => {
271
+ this.log(area, `query failed to start: ${error.message}`);
272
+ reject(error);
273
+ });
163
274
  child.once("close", (code) => {
275
+ this.log(area, `query exited with code ${code}`);
164
276
  if (code !== 0)
165
277
  return reject(new Error(err.trim() || `Exited with code ${code}.`));
166
278
  try {
@@ -198,10 +310,11 @@ class ServiceJobRunner {
198
310
  else {
199
311
  child.kill("SIGTERM");
200
312
  }
313
+ this.log(this.area(job.kind), `canceling job ${job.id}`);
201
314
  this.finish(job, "canceled", "Canceled.");
202
315
  return this.list();
203
316
  }
204
- ingestOutput(job, chunk) {
317
+ ingestOutput(job, source, chunk) {
205
318
  for (const line of chunk
206
319
  .split(/[\r\n]+/u)
207
320
  .map((value) => value.trim())
@@ -209,6 +322,7 @@ class ServiceJobRunner {
209
322
  const progress = /(\d{1,3})\s*%/u.exec(line);
210
323
  if (progress)
211
324
  job.percent = Math.max(0, Math.min(100, Number(progress[1])));
325
+ this.log(this.area(job.kind), `job ${job.id} ${source}: ${line}`);
212
326
  // The final JSON result is not a useful status label. Keep progress and
213
327
  // actionable text instead, so a failed bundle pull tells the user why.
214
328
  if (line !== "[" && !/^[\]{}",]+$/u.test(line))
@@ -225,8 +339,17 @@ class ServiceJobRunner {
225
339
  job.finishedAt = new Date().toISOString();
226
340
  if (status === "succeeded")
227
341
  job.percent = 100;
342
+ this.log(this.area(job.kind), `job ${job.id} ${status}${error ? `: ${error}` : ""}`);
228
343
  }
229
- publicJob({ child: _child, controller: _controller, ...job }) {
344
+ logOutput(area, source, chunk) {
345
+ for (const line of chunk
346
+ .split(/[\r\n]+/u)
347
+ .map((entry) => entry.trim())
348
+ .filter(Boolean)) {
349
+ this.log(area, `${source}: ${line}`);
350
+ }
351
+ }
352
+ publicJob({ child: _child, controller: _controller, pull: _pull, ...job }) {
230
353
  return job;
231
354
  }
232
355
  }
@@ -272,9 +395,16 @@ function withAuth(inner, token) {
272
395
  }
273
396
  export async function startService({ config, modelNeedle, env = process.env, onLog = () => { }, }) {
274
397
  const runLog = createBrainRunLog(env);
275
- const log = (line) => {
276
- runLog.write(line);
277
- onLog(line);
398
+ const logEvents = new BrainLogPublisher();
399
+ const log = (area, message) => {
400
+ const line = formatBrainLog(area, message);
401
+ for (const entry of runLog.write(line)) {
402
+ logEvents.publish(entry);
403
+ // The daemon also captures this foreground child's stderr before the
404
+ // management listener exists. Give it the exact durable entry, rather
405
+ // than a second un-timestamped rendering of the same event.
406
+ onLog(entry);
407
+ }
278
408
  };
279
409
  // The management API must be useful before any setup exists: the Brain page
280
410
  // is where the owner downloads both a runtime and their first model. Keep the
@@ -331,7 +461,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
331
461
  // service down. An explicit CLI selection remains an actionable error.
332
462
  if (modelNeedle)
333
463
  throw error;
334
- log(`note: ${error instanceof Error ? error.message : "configured model is unavailable"}`);
464
+ log("server", `note: ${error instanceof Error ? error.message : "configured model is unavailable"}`);
335
465
  }
336
466
  }
337
467
  let profile = model ? forModel(store, model, config.defaults) : null;
@@ -340,66 +470,69 @@ export async function startService({ config, modelNeedle, env = process.env, onL
340
470
  const fit = vram.fitToBudget({
341
471
  model,
342
472
  profile,
343
- calibration: getCalibration(store, model, profile),
473
+ calibration: getCalibrationForBudget(store, model, profile),
344
474
  totalVramBytes: gpu.totalBytes,
345
475
  });
346
476
  if (!fit.adjusted && !fit.budget.fits) {
347
477
  // Starting the host is what exposes the Library and model profile UI.
348
478
  // An automatic startup candidate that cannot load must therefore leave
349
479
  // the host alive and unloaded, not make the only recovery surface vanish.
350
- log(`note: not loading ${model.displayName}: ${fit.reason ?? "does not fit in available VRAM"}`);
480
+ log("model", `note: not loading ${model.displayName}: ${fit.reason ?? "does not fit in available VRAM"}`);
351
481
  model = null;
352
482
  profile = null;
353
483
  }
354
484
  else {
355
485
  if (fit.adjusted && fit.reason)
356
- log(`note: ${fit.reason}`);
486
+ log("model", `note: ${fit.reason}`);
357
487
  profile = fit.profile;
358
488
  }
359
489
  }
360
490
  const telemetry = new Telemetry();
361
- const supervisor = new Supervisor({ runtime, paths, getProfilesStore: () => store });
362
- supervisor.on("log", (line) => {
363
- runLog.write(line);
364
- if (/error|failed|warn/i.test(line))
365
- onLog(line);
491
+ const supervisor = new Supervisor({
492
+ runtime,
493
+ paths,
494
+ getProfilesStore: () => store,
495
+ logVerbosity: config.runtime.logVerbosity,
366
496
  });
367
- supervisor.on("crashed", (error) => runLog.write(`FATAL ${error}`));
368
- // Serialize model switches: the router queues request-driven switches, but the
369
- // config path (POST /__host/config) calls loadModel directly. Chaining here
370
- // guarantees two switches (e.g. a config write racing a request-driven switch)
371
- // can never overlap two supervisor.start() calls, whichever caller triggers them.
372
- let modelSwitchChain = Promise.resolve();
373
- const loadModelUnsafe = async (target) => {
497
+ supervisor.on("log", (line) => log("server", line));
498
+ supervisor.on("crashed", (error) => log("model", `FATAL ${error}`));
499
+ const loadModelInto = async (resident, target, reservedElsewhereBytes) => {
374
500
  // A runtime can be installed from the Library tab after this service starts.
375
501
  // Resolve it at load time so the user does not have to restart the brain.
376
- supervisor.runtime = resolveRuntime(config, env);
377
- if (!supervisor.runtime) {
502
+ resident.runtime = resolveRuntime(config, env);
503
+ if (!resident.runtime) {
378
504
  throw new Error("no llama.cpp runtime available; install one from the Library tab");
379
505
  }
380
506
  const gpuInfo = await queryGpu();
381
507
  let fitProfile = forModel(store, target, config.defaults);
508
+ let reservationBytes = 0;
382
509
  if (gpuInfo) {
383
510
  const fit = vram.fitToBudget({
384
511
  model: target,
385
512
  profile: fitProfile,
386
- calibration: getCalibration(store, target, fitProfile),
387
- totalVramBytes: gpuInfo.totalBytes,
513
+ calibration: getCalibrationForBudget(store, target, fitProfile),
514
+ // Every resident process keeps its complete budget reserved. Fit this
515
+ // process against the capacity left after those independent allocations.
516
+ totalVramBytes: Math.max(0, gpuInfo.totalBytes - reservedElsewhereBytes),
388
517
  });
389
518
  if (!fit.adjusted && !fit.budget.fits)
390
519
  throw new Error(fit.reason ?? "does not fit");
391
520
  fitProfile = fit.profile;
521
+ reservationBytes = fit.budget.totalBytes;
392
522
  }
393
- await supervisor.start(target, fitProfile);
523
+ await resident.start(target, fitProfile);
394
524
  delete store.pendingReloadModelIds[target.id];
395
525
  store.lastModelId = target.id;
396
526
  saveProfilesStore(store, paths);
527
+ return reservationBytes;
397
528
  };
398
- const loadModel = (target) => {
399
- const run = modelSwitchChain.then(() => loadModelUnsafe(target));
400
- // Keep the chain alive even if this switch fails, so a later switch still runs.
401
- modelSwitchChain = run.catch(() => undefined);
402
- return run;
529
+ let processPool = null;
530
+ const loadModel = async (target) => {
531
+ if (processPool) {
532
+ await processPool.preload(target);
533
+ return;
534
+ }
535
+ await loadModelInto(supervisor, target, 0);
403
536
  };
404
537
  // Apply an editable config patch from POST /__host/config: mutate the live
405
538
  // config (so the lock/default getters and future starts see it), persist it to
@@ -425,15 +558,42 @@ export async function startService({ config, modelNeedle, env = process.env, onL
425
558
  throw new Error("lockModel must be a boolean");
426
559
  config.lockModel = p.lockModel;
427
560
  }
561
+ if ("maxLoadedModels" in p) {
562
+ const next = p.maxLoadedModels;
563
+ if (!Number.isInteger(next) || next < 1 || next > 16) {
564
+ throw new Error("maxLoadedModels must be an integer from 1 to 16");
565
+ }
566
+ config.maxLoadedModels = next;
567
+ }
568
+ if ("lockedModels" in p) {
569
+ const next = p.lockedModels;
570
+ if (!Array.isArray(next) || !next.every((value) => typeof value === "string")) {
571
+ throw new Error("lockedModels must be an array of model ids");
572
+ }
573
+ config.lockedModels = [...new Set(next)].slice(0, config.maxLoadedModels);
574
+ }
575
+ if (config.lockedModels.length > config.maxLoadedModels) {
576
+ config.lockedModels = config.lockedModels.slice(0, config.maxLoadedModels);
577
+ }
428
578
  const persisted = loadPersistedConfig(paths);
429
579
  persisted.defaultModel = config.defaultModel;
430
580
  persisted.lockModel = config.lockModel;
581
+ persisted.maxLoadedModels = config.maxLoadedModels;
582
+ persisted.lockedModels = config.lockedModels;
431
583
  saveBrainConfig(persisted, paths);
584
+ await processPool?.configure(config.maxLoadedModels);
432
585
  if (switchTo) {
433
586
  const target = catalog.find((m) => m.displayName === switchTo || m.id === switchTo);
434
587
  if (target)
435
588
  await loadModel(target);
436
589
  }
590
+ if (config.lockModel) {
591
+ for (const id of config.lockedModels) {
592
+ const target = catalog.find((candidate) => candidate.id === id || candidate.displayName === id);
593
+ if (target)
594
+ await loadModel(target);
595
+ }
596
+ }
437
597
  return redactConfig(config);
438
598
  };
439
599
  // One CPU sampler for the lifetime of the service: it reports a busy fraction
@@ -444,7 +604,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
444
604
  // /__host/events. One instance, so `capabilities.events` and the stream can
445
605
  // never disagree about whether this brain publishes.
446
606
  const statusEvents = new BrainStatusPublisher();
447
- const runResidentJob = async (kind, target, update, signal) => {
607
+ const runResidentJob = async (supervisor, kind, target, update, signal) => {
448
608
  const ensureActive = () => {
449
609
  if (signal.aborted)
450
610
  throw new Error("Operation canceled.");
@@ -455,13 +615,6 @@ export async function startService({ config, modelNeedle, env = process.env, onL
455
615
  : null;
456
616
  if (!targetModel)
457
617
  throw new Error("No installed model is available for this operation.");
458
- const restoreModel = supervisor.model;
459
- const restoreProfile = supervisor.profile;
460
- const restore = async () => {
461
- if (restoreModel && restoreProfile && !signal.aborted) {
462
- await supervisor.start(restoreModel, restoreProfile, { preserveLogs: true });
463
- }
464
- };
465
618
  if (kind === "calibrate") {
466
619
  const runtime = supervisor.runtime ?? resolveRuntime(config, env);
467
620
  if (!runtime)
@@ -469,34 +622,29 @@ export async function startService({ config, modelNeedle, env = process.env, onL
469
622
  const profile = forModel(store, targetModel, config.defaults);
470
623
  update.message(`Calibrating ${targetModel.displayName}`);
471
624
  supervisor.recordLog(`operation calibrate: ${targetModel.displayName}`);
472
- try {
473
- const measurement = await calibrate({
474
- runtime,
475
- model: targetModel,
476
- profile,
477
- supervisor,
478
- onProgress: (event) => {
479
- ensureActive();
480
- const message = event.phase === "loading"
481
- ? `Calibrating ${event.contextSize.toLocaleString()} context`
482
- : event.phase === "measured"
483
- ? `Measured ${event.contextSize.toLocaleString()} context`
484
- : (event.reason ??
485
- event.error ??
486
- `Skipped ${event.contextSize.toLocaleString()} context`);
487
- update.message(message);
488
- supervisor.recordLog(`operation calibrate: ${message}`);
489
- },
490
- });
491
- ensureActive();
492
- putCalibration(store, targetModel, profile, measurement);
493
- saveProfilesStore(store, paths);
494
- update.percent(100);
495
- supervisor.recordLog(`operation calibrate: saved measurement for ${targetModel.displayName}`);
496
- }
497
- finally {
498
- await restore();
499
- }
625
+ const measurement = await calibrate({
626
+ runtime,
627
+ model: targetModel,
628
+ profile,
629
+ supervisor,
630
+ onProgress: (event) => {
631
+ ensureActive();
632
+ const message = event.phase === "loading"
633
+ ? `Calibrating ${event.contextSize.toLocaleString()} context`
634
+ : event.phase === "measured"
635
+ ? `Measured ${event.contextSize.toLocaleString()} context`
636
+ : (event.reason ??
637
+ event.error ??
638
+ `Skipped ${event.contextSize.toLocaleString()} context`);
639
+ update.message(message);
640
+ supervisor.recordLog(`operation calibrate: ${message}`);
641
+ },
642
+ });
643
+ ensureActive();
644
+ putCalibration(store, targetModel, profile, measurement);
645
+ saveProfilesStore(store, paths);
646
+ update.percent(100);
647
+ supervisor.recordLog(`operation calibrate: saved measurement for ${targetModel.displayName}`);
500
648
  return;
501
649
  }
502
650
  if (kind === "sweep") {
@@ -506,47 +654,42 @@ export async function startService({ config, modelNeedle, env = process.env, onL
506
654
  const profile = forModel(store, targetModel, config.defaults);
507
655
  update.message(`Sweeping ${targetModel.displayName}`);
508
656
  supervisor.recordLog(`operation sweep: ${targetModel.displayName}`);
509
- try {
510
- const report = await sweep({
511
- runtime,
512
- model: targetModel,
513
- profile,
514
- supervisor,
515
- onProgress: (event) => {
516
- ensureActive();
517
- const message = event.phase === "loading"
518
- ? `Budget ${event.budget}: loading`
519
- : event.phase === "generating"
520
- ? `Budget ${event.budget}: generating`
521
- : event.phase === "done"
522
- ? `Budget ${event.budget}: complete`
523
- : `Budget ${event.budget}: ${event.error ?? "failed"}`;
524
- update.message(message);
525
- supervisor.recordLog(`operation sweep: ${message}`);
526
- },
527
- });
528
- ensureActive();
529
- if (report.recommended !== null) {
530
- profile.reasoningBudget = report.recommended;
531
- put(store, targetModel, profile);
532
- saveProfilesStore(store, paths);
533
- supervisor.recordLog(`operation sweep: saved budget ${report.recommended}`);
534
- }
535
- update.percent(100);
536
- }
537
- finally {
538
- await restore();
657
+ const report = await sweep({
658
+ runtime,
659
+ model: targetModel,
660
+ profile,
661
+ supervisor,
662
+ onProgress: (event) => {
663
+ ensureActive();
664
+ const message = event.phase === "loading"
665
+ ? `Budget ${event.budget}: loading`
666
+ : event.phase === "generating"
667
+ ? `Budget ${event.budget}: generating`
668
+ : event.phase === "done"
669
+ ? `Budget ${event.budget}: complete`
670
+ : `Budget ${event.budget}: ${event.error ?? "failed"}`;
671
+ update.message(message);
672
+ supervisor.recordLog(`operation sweep: ${message}`);
673
+ },
674
+ });
675
+ ensureActive();
676
+ if (report.recommended !== null) {
677
+ profile.reasoningBudget = report.recommended;
678
+ put(store, targetModel, profile);
679
+ saveProfilesStore(store, paths);
680
+ supervisor.recordLog(`operation sweep: saved budget ${report.recommended}`);
539
681
  }
682
+ update.percent(100);
540
683
  return;
541
684
  }
542
685
  update.message(`Benchmarking ${targetModel.displayName}`);
543
686
  supervisor.recordLog(`operation benchmark: ${targetModel.displayName}`);
544
- await loadModel(targetModel);
687
+ // Scheduler loaded this exact model before admitting the exclusive turn.
545
688
  ensureActive();
546
689
  supervisor.recordLog(`operation benchmark: resident model ready`);
547
690
  const profile = supervisor.profile ?? forModel(store, targetModel, config.defaults);
548
691
  const gpuInfo = await queryGpu();
549
- const calibration = getCalibration(store, targetModel, profile);
692
+ const calibration = getCalibrationForBudget(store, targetModel, profile);
550
693
  const fit = gpuInfo
551
694
  ? vram.fitToBudget({
552
695
  model: targetModel,
@@ -590,7 +733,58 @@ export async function startService({ config, modelNeedle, env = process.env, onL
590
733
  update.percent(100);
591
734
  supervisor.recordLog(`operation benchmark: saved result for ${targetModel.displayName}`);
592
735
  };
593
- const jobs = new ServiceJobRunner(rescanCatalog, runResidentJob);
736
+ const attachSupervisorLogs = (resident) => {
737
+ if (resident === supervisor)
738
+ return;
739
+ resident.on("log", (line) => log("server", line));
740
+ resident.on("crashed", (error) => log("model", `FATAL ${error}`));
741
+ resident.on("state", () => statusEvents.notify());
742
+ };
743
+ const createPooledSupervisor = (index) => {
744
+ const resident = new Supervisor({
745
+ runtime: resolveRuntime(config, env),
746
+ internalPort: supervisor.internalPort + index,
747
+ paths,
748
+ getProfilesStore: () => store,
749
+ logVerbosity: config.runtime.logVerbosity,
750
+ });
751
+ attachSupervisorLogs(resident);
752
+ return resident;
753
+ };
754
+ const createResidentScheduler = (resident, loadResidentModel, onChange) => new Scheduler({
755
+ supervisor: resident,
756
+ loadModel: loadResidentModel,
757
+ logger: (message) => log("api", `WARN ${message}`),
758
+ onChange,
759
+ freeSlots: async () => {
760
+ if (resident.state !== "ready")
761
+ return null;
762
+ try {
763
+ const slots = await sampleSlots({ host: resident.host, port: resident.internalPort });
764
+ return slots ? { idle: slots.idle, ids: slots.idleSlots } : null;
765
+ }
766
+ catch {
767
+ return null;
768
+ }
769
+ },
770
+ eraseSlot: createSlotEraser(resident.host, resident.internalPort),
771
+ });
772
+ const scheduler = new ModelProcessPool({
773
+ initialSupervisor: supervisor,
774
+ maxModels: config.maxLoadedModels,
775
+ createSupervisor: createPooledSupervisor,
776
+ createScheduler: createResidentScheduler,
777
+ loadModel: loadModelInto,
778
+ logger: (message) => log("model", message),
779
+ onChange: () => statusEvents.notify(),
780
+ });
781
+ processPool = scheduler;
782
+ const jobs = new ServiceJobRunner(rescanCatalog, runResidentJob, scheduler, (target) => {
783
+ const modelId = target ?? scheduler.residentSupervisors()[0]?.model?.id ?? store.lastModelId ?? null;
784
+ return modelId
785
+ ? (catalog.find((candidate) => candidate.id === modelId || candidate.displayName === modelId) ?? null)
786
+ : null;
787
+ }, log);
594
788
  // Assigned once `stop` exists below. This indirection lets the management API
595
789
  // answer a remote restart request before closing its own socket.
596
790
  let requestRestart = () => { };
@@ -611,6 +805,8 @@ export async function startService({ config, modelNeedle, env = process.env, onL
611
805
  }
612
806
  },
613
807
  loadModel,
808
+ unloadModels: () => scheduler.unload(),
809
+ scheduler,
614
810
  // The same gate as POST /__host/config. Deleting someone's model files over
615
811
  // the network is strictly more dangerous than changing their default model,
616
812
  // so it does not get a weaker one.
@@ -618,13 +814,19 @@ export async function startService({ config, modelNeedle, env = process.env, onL
618
814
  getModelsDir: () => managedModelsDir(config, env),
619
815
  sampleResources: () => sampleSystem(cpuSampler, { host: supervisor.host, port: supervisor.internalPort }),
620
816
  statusEvents,
817
+ logEvents,
621
818
  jobs,
819
+ runLog,
622
820
  restart: () => requestRestart(),
821
+ log,
623
822
  });
624
823
  const handler = withAuth(createRouter({
625
824
  supervisor,
626
825
  telemetry,
627
- logger: { warn: (m) => log(`WARN ${m}`) },
826
+ logger: {
827
+ info: (m) => log("api", m),
828
+ warn: (m) => log("api", `WARN ${m}`),
829
+ },
628
830
  getCatalog: () => catalog,
629
831
  loadModel,
630
832
  version: resolveVersion(),
@@ -632,6 +834,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
632
834
  getEvals: collectEvals,
633
835
  getLockModel: () => config.lockModel,
634
836
  getDefaultModel: () => config.defaultModel,
837
+ getLockedModels: () => config.lockedModels,
635
838
  applyConfigPatch,
636
839
  getAllowConfigWrite: allowWrite,
637
840
  hostApi,
@@ -639,6 +842,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
639
842
  // time in parallel: the shared rate tracker needs one ordered timeline.
640
843
  getResources: () => sampleSystem(cpuSampler),
641
844
  statusEvents,
845
+ scheduler,
642
846
  }), authToken);
643
847
  // TLS terminates in-process when configured; otherwise plain HTTP. The cert
644
848
  // manager issues/generates the first keypair before we listen, and hot-swaps
@@ -646,12 +850,18 @@ export async function startService({ config, modelNeedle, env = process.env, onL
646
850
  let certManager = null;
647
851
  let server;
648
852
  if (tlsOptions) {
649
- certManager = new CertManager({ ...tlsOptions, logger: { info: onLog, warn: onLog } });
853
+ certManager = new CertManager({
854
+ ...tlsOptions,
855
+ logger: {
856
+ info: (message) => log("server", message),
857
+ warn: (message) => log("server", message),
858
+ },
859
+ });
650
860
  const secure = await certManager.load();
651
861
  const httpsServer = https.createServer({ key: secure.key, cert: secure.cert }, handler);
652
862
  certManager.on("renewed", (pair) => {
653
863
  httpsServer.setSecureContext({ key: pair.key, cert: pair.cert });
654
- onLog("note: TLS certificate hot-swapped");
864
+ log("server", "note: TLS certificate hot-swapped");
655
865
  });
656
866
  server = httpsServer;
657
867
  }
@@ -660,22 +870,38 @@ export async function startService({ config, modelNeedle, env = process.env, onL
660
870
  }
661
871
  server.keepAliveTimeout = 75000;
662
872
  server.requestTimeout = 0;
663
- await new Promise((resolve, reject) => {
664
- server.once("error", reject);
665
- server.listen(port, bindHost, resolve);
666
- });
873
+ try {
874
+ await new Promise((resolve, reject) => {
875
+ server.once("error", reject);
876
+ server.listen(port, bindHost, resolve);
877
+ });
878
+ }
879
+ catch (error) {
880
+ // A listener can fail before the host API exists, so this cannot travel
881
+ // through the host's own SSE endpoint. It still belongs to this service
882
+ // session: the foreground daemon child relays this timestamped entry until
883
+ // that endpoint becomes available.
884
+ log("server", `FATAL Brain service startup failed: ${errorMessage(error)}`);
885
+ throw error;
886
+ }
667
887
  certManager?.start();
668
- if (model && profile && runtime) {
669
- await supervisor.start(model, profile);
670
- delete store.pendingReloadModelIds[model.id];
671
- store.lastModelId = model.id;
672
- saveProfilesStore(store, paths);
888
+ if (runtime && config.lockModel && config.lockedModels.length > 0) {
889
+ for (const lockedId of config.lockedModels.slice(0, config.maxLoadedModels)) {
890
+ const locked = catalog.find((candidate) => candidate.id === lockedId || candidate.displayName === lockedId);
891
+ if (locked)
892
+ await scheduler.preload(locked);
893
+ }
894
+ }
895
+ else if (model && profile && runtime) {
896
+ // Default model remains one auto-load. Additional process slots stay empty
897
+ // until a request names another model.
898
+ await scheduler.preload(model);
673
899
  }
674
900
  else if (!runtime) {
675
- log("ready: no llama.cpp runtime installed; use the Library tab to download one");
901
+ log("server", "ready: no llama.cpp runtime installed; use the Library tab to download one");
676
902
  }
677
903
  else {
678
- log("ready: no model installed; use the Library tab to download one");
904
+ log("server", "ready: no model installed; use the Library tab to download one");
679
905
  }
680
906
  writePidFile({
681
907
  pid: process.pid,
@@ -685,14 +911,22 @@ export async function startService({ config, modelNeedle, env = process.env, onL
685
911
  secure: Boolean(tlsOptions),
686
912
  displayHost,
687
913
  }, env);
688
- log(`ready: ${supervisor.model?.displayName ?? "no model loaded"} on ${bindHost}:${port}; run log ${runLog.path}`);
914
+ log("server", `ready: ${scheduler
915
+ .residentSupervisors()
916
+ .map((resident) => resident.model?.displayName)
917
+ .filter(Boolean)
918
+ .join(", ") || "no model loaded"} on ${bindHost}:${port}; run log ${runLog.path}`);
689
919
  const stop = async () => {
690
920
  certManager?.stop();
691
- log("Brain service stopping");
921
+ log("server", "Brain service stopping");
922
+ await scheduler.stop();
923
+ // Publish the terminal outcome before closing the SSE responses below.
924
+ // Once the listener is closed, the daemon can still report the child exit,
925
+ // but it cannot receive this service-owned, durable session-log entry.
926
+ log("server", "Brain service stopped");
692
927
  // Before server.close(), which waits on open connections: a subscribed
693
928
  // daemon holds an SSE response open indefinitely by design.
694
929
  statusEvents.close();
695
- await supervisor.stop();
696
930
  server.closeIdleConnections?.();
697
931
  await new Promise((resolve) => server.close(() => resolve()));
698
932
  removePidFile(env);