@otto-code/brain 0.8.10 → 0.8.12

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 (59) hide show
  1. package/dist/commands/calibrate.js +9 -0
  2. package/dist/commands/catalog.d.ts +1 -0
  3. package/dist/commands/catalog.js +1 -0
  4. package/dist/commands/pull.d.ts +1 -0
  5. package/dist/commands/pull.js +12 -3
  6. package/dist/commands/search.d.ts +1 -0
  7. package/dist/commands/search.js +12 -2
  8. package/dist/config/index.d.ts +1 -1
  9. package/dist/config/index.js +1 -1
  10. package/dist/config/profile-edit.d.ts +88 -1
  11. package/dist/config/profile-edit.js +280 -29
  12. package/dist/config/profiles.js +16 -0
  13. package/dist/config/schema.d.ts +608 -0
  14. package/dist/config/schema.js +58 -0
  15. package/dist/config/store.js +7 -4
  16. package/dist/gguf.d.ts +7 -0
  17. package/dist/gguf.js +15 -2
  18. package/dist/models/download.d.ts +1 -1
  19. package/dist/models/download.js +2 -2
  20. package/dist/models/enrich.d.ts +6 -0
  21. package/dist/models/enrich.js +27 -1
  22. package/dist/models/index.d.ts +1 -1
  23. package/dist/models/index.js +4 -3
  24. package/dist/ops/calibrate.d.ts +38 -3
  25. package/dist/ops/calibrate.js +68 -19
  26. package/dist/ops/report.js +51 -1
  27. package/dist/ops/results.d.ts +57 -11
  28. package/dist/ops/results.js +75 -10
  29. package/dist/ops/sweep.d.ts +38 -1
  30. package/dist/ops/sweep.js +61 -10
  31. package/dist/runtime/args.d.ts +15 -2
  32. package/dist/runtime/args.js +60 -5
  33. package/dist/runtime/managed.js +2 -2
  34. package/dist/service/activity.d.ts +19 -0
  35. package/dist/service/activity.js +47 -4
  36. package/dist/service/host-api.d.ts +25 -4
  37. package/dist/service/host-api.js +82 -16
  38. package/dist/service/log-format.d.ts +18 -0
  39. package/dist/service/log-format.js +32 -0
  40. package/dist/service/router.d.ts +70 -2
  41. package/dist/service/router.js +219 -21
  42. package/dist/service/run-log.d.ts +6 -1
  43. package/dist/service/run-log.js +46 -4
  44. package/dist/service/scheduler.d.ts +227 -24
  45. package/dist/service/scheduler.js +395 -63
  46. package/dist/service/serve.d.ts +4 -0
  47. package/dist/service/serve.js +302 -117
  48. package/dist/service/status-events.d.ts +14 -1
  49. package/dist/service/status-events.js +111 -12
  50. package/dist/service/supervisor.d.ts +9 -7
  51. package/dist/service/supervisor.js +37 -12
  52. package/dist/sysmon.d.ts +15 -0
  53. package/dist/sysmon.js +56 -9
  54. package/dist/tui/app.d.ts +8 -2
  55. package/dist/tui/app.js +65 -17
  56. package/dist/types.d.ts +18 -0
  57. package/dist/vram.d.ts +37 -0
  58. package/dist/vram.js +57 -18
  59. package/package.json +1 -1
@@ -3,18 +3,68 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
3
3
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
4
4
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
5
5
  };
6
- var _Scheduler_instances, _Scheduler_announce, _Scheduler_take, _Scheduler_serveTurn;
6
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
7
+ if (kind === "m") throw new TypeError("Private method is not writable");
8
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
9
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
10
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
11
+ };
12
+ var _Scheduler_instances, _Scheduler_batch, _Scheduler_running, _Scheduler_turnId, _Scheduler_freeSlotIds, _Scheduler_busy, _Scheduler_dirty, _Scheduler_slotTimer, _Scheduler_announce, _Scheduler_takeTurn, _Scheduler_take, _Scheduler_warmSessions, _Scheduler_claimJob, _Scheduler_sampleFreeSlots, _Scheduler_pollForSlot, _Scheduler_start, _Scheduler_eraseFor, _Scheduler_dispatch, _Scheduler_resetSlots, _Scheduler_pass;
7
13
  const MAX_CONCURRENCY = 16;
14
+ /** A short, safe description of an unknown error value for log lines. */
15
+ function describeError(error) {
16
+ return error instanceof Error ? error.message : String(error);
17
+ }
8
18
  export class Scheduler {
9
- constructor({ supervisor, loadModel, logger = null, onChange = null }) {
19
+ constructor({ supervisor, loadModel, logger = null, onChange = null, freeSlots = null, slotPollMs = 2500, eraseSlot = null, }) {
10
20
  _Scheduler_instances.add(this);
21
+ /** Submitted, not yet claimed by a turn. */
22
+ this.queue = [];
23
+ this.lastTurnId = null;
24
+ /**
25
+ * Per model: the session whose jobs most recently filled its slots. That
26
+ * session's KV state is the one llama-server's LCP selection will match
27
+ * against, so its queued jobs go first on the next dispatch.
28
+ */
29
+ this.hotSessions = new Map();
30
+ /** The exclusive operation in flight, if any. Reported by `stats()`. */
31
+ this.activeJob = null;
32
+ /**
33
+ * Engine slot -> the session that last ran on it (see OWNERSHIP). The value
34
+ * is null for keyless jobs: a keyless job cannot prove ownership of anything
35
+ * after it settles, so the next keyed chat landing on that slot is treated as
36
+ * a handoff and the slot is erased for it. Wiped wholesale whenever the
37
+ * engine reloads a model - slot ids do not survive the relaunch.
38
+ */
39
+ this.slotOwners = new Map();
40
+ /** Claimed by the current turn, waiting for a slot. Empty between turns. */
41
+ _Scheduler_batch.set(this, []);
42
+ /** Started, not yet settled. Invariant: every member's model is `#turnId`. */
43
+ _Scheduler_running.set(this, new Set());
44
+ /** The model that owns the engine for this turn, or null between turns. */
45
+ _Scheduler_turnId.set(this, null);
46
+ /**
47
+ * Distinct engine slot ids, one per job this pass may still admit, drawn from
48
+ * the pass's own free-slot sample (`null` when the engine reported no
49
+ * per-slot rows - then nothing is pinned). A job pops one as it is admitted
50
+ * in `#start`, so two jobs admitted in the same pass can never be named the
51
+ * same slot - the failure mode a job-side sample at dispatch time would
52
+ * produce. Reset whenever the engine is relaunched, because slot ids do not
53
+ * survive a model switch.
54
+ */
55
+ _Scheduler_freeSlotIds.set(this, null);
56
+ /** A dispatch pass is running. Only one may read/mutate the state at a time. */
57
+ _Scheduler_busy.set(this, false);
58
+ /** State changed mid-pass; run one more pass before yielding. */
59
+ _Scheduler_dirty.set(this, false);
60
+ _Scheduler_slotTimer.set(this, null);
11
61
  this.supervisor = supervisor;
12
62
  this.loadModel = loadModel; // async (model) => resolves once it is ready
13
63
  this.logger = logger; // optional (message: string) => void
14
- this.queue = []; // { modelId, model, run, resolve, reject }
15
- this.lastTurnId = null;
16
- this.pumping = false;
17
64
  this.onChange = onChange;
65
+ this.freeSlots = freeSlots;
66
+ this.slotPollMs = slotPollMs;
67
+ this.eraseSlot = eraseSlot;
18
68
  }
19
69
  /** Id of the model that is actually loaded and ready, or null. */
20
70
  get loadedId() {
@@ -22,72 +72,94 @@ export class Scheduler {
22
72
  ? this.supervisor.model.id
23
73
  : null;
24
74
  }
25
- /** How many requests may run at once against the resident model. */
75
+ /** How many requests may run at once against the resident model (static ceiling). */
26
76
  get concurrency() {
27
77
  return Math.max(1, Math.min(MAX_CONCURRENCY, this.supervisor.profile?.parallelSlots || 1));
28
78
  }
29
79
  /**
30
80
  * Queue a job for an already-resolved catalog model. `run` is invoked once
31
81
  * that model is the resident one; the returned promise settles when run does.
32
- * Pumping is deferred a microtask so a burst of requests submitted together
33
- * share one turn rather than the first one snapshotting a turn by itself.
82
+ * Dispatch is deferred a microtask so a burst of requests submitted together
83
+ * shares one turn rather than the first one taking a turn by itself.
34
84
  */
35
- submit(model, run) {
85
+ submit(model, run, { kind = "completion", exclusive = kind !== "completion", onStart = null, session = null, onSlotFree = null, } = {}) {
36
86
  return new Promise((resolve, reject) => {
37
- this.queue.push({ modelId: model.id, model, run, resolve, reject });
87
+ this.queue.push({
88
+ modelId: model.id,
89
+ model,
90
+ kind,
91
+ exclusive,
92
+ session: session ?? null,
93
+ onStart,
94
+ onSlotFree,
95
+ slotId: null,
96
+ run,
97
+ resolve,
98
+ reject,
99
+ });
38
100
  __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_announce).call(this);
39
- queueMicrotask(() => this.pump());
101
+ queueMicrotask(() => void __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_dispatch).call(this));
40
102
  });
41
103
  }
42
- async pump() {
43
- if (this.pumping)
44
- return;
45
- this.pumping = true;
46
- try {
47
- while (this.queue.length) {
48
- // Prefer a model other than the one that just took a turn, so the two
49
- // sides alternate; fall back to the head of the queue when only one
50
- // model is waiting (it simply keeps its turn).
51
- const pick = this.queue.find((j) => j.modelId !== this.lastTurnId) || this.queue[0];
52
- const turnId = pick.modelId;
53
- if (this.loadedId !== turnId) {
54
- try {
55
- this.logger?.(`switching to ${pick.model.displayName}`);
56
- await this.loadModel(pick.model);
57
- }
58
- catch (error) {
59
- // The model would not load - fail exactly its queued jobs and move on.
60
- for (const job of __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_take).call(this, (j) => j.modelId === turnId))
61
- job.reject(error);
62
- continue;
63
- }
64
- }
65
- this.lastTurnId = turnId;
66
- __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_announce).call(this);
67
- await __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_serveTurn).call(this, turnId);
68
- }
69
- }
70
- finally {
71
- this.pumping = false;
72
- }
104
+ /**
105
+ * Drop every recorded slot owner. The engine's slots do not survive a model
106
+ * (re)launch, so their owners do not either - a stale entry would make the
107
+ * next admission think a FRESH slot still holds a previous chat's KV and
108
+ * erase it (an unnecessary re-prefill), or, worse, let a keyless job be
109
+ * mistaken for the owner of a slot it is not.
110
+ *
111
+ * Called from two places: `#pass` clears the owners the moment a turn begins
112
+ * on a different model than the one it last served (a model switch, where
113
+ * the scheduler itself sees the relaunch), and the router calls it on the
114
+ * supervisor's `starting` state - the relaunch that keeps the SAME model
115
+ * resident (a live profile edit), where the turn never changes and only the
116
+ * supervisor says the slots are gone. Both paths are idempotent.
117
+ */
118
+ forgetSlots() {
119
+ this.slotOwners.clear();
73
120
  }
74
121
  /** Queue snapshot for the status endpoint / UI. */
75
122
  stats() {
76
123
  const waiting = {};
77
- for (const job of this.queue) {
124
+ const waitingModelIds = {};
125
+ // Batched jobs have left the queue but have not started: to everyone
126
+ // outside the scheduler they are still waiting.
127
+ for (const job of [...this.queue, ...__classPrivateFieldGet(this, _Scheduler_batch, "f")]) {
78
128
  const name = job.model.displayName;
79
129
  waiting[name] = (waiting[name] || 0) + 1;
130
+ waitingModelIds[job.modelId] = (waitingModelIds[job.modelId] || 0) + 1;
80
131
  }
81
- return { queued: this.queue.length, waiting, lastTurn: this.lastTurnId };
132
+ const active = this.activeJob && this.activeJob.kind !== "completion"
133
+ ? { modelId: this.activeJob.modelId, kind: this.activeJob.kind }
134
+ : null;
135
+ return {
136
+ queued: this.queue.length + __classPrivateFieldGet(this, _Scheduler_batch, "f").length,
137
+ waiting,
138
+ waitingModelIds,
139
+ lastTurn: this.lastTurnId,
140
+ active,
141
+ };
82
142
  }
83
143
  }
84
- _Scheduler_instances = new WeakSet(), _Scheduler_announce = function _Scheduler_announce() {
144
+ _Scheduler_batch = new WeakMap(), _Scheduler_running = new WeakMap(), _Scheduler_turnId = new WeakMap(), _Scheduler_freeSlotIds = new WeakMap(), _Scheduler_busy = new WeakMap(), _Scheduler_dirty = new WeakMap(), _Scheduler_slotTimer = new WeakMap(), _Scheduler_instances = new WeakSet(), _Scheduler_announce = function _Scheduler_announce() {
85
145
  try {
86
146
  this.onChange?.();
87
147
  }
88
148
  catch {
89
149
  // Status reporting is not allowed to fail a queued completion.
90
150
  }
151
+ }, _Scheduler_takeTurn = function _Scheduler_takeTurn(turnId) {
152
+ const taken = [];
153
+ for (const job of this.queue) {
154
+ if (job.modelId !== turnId)
155
+ continue;
156
+ if (taken.length > 0 && job.exclusive)
157
+ break; // boundary waits its turn
158
+ taken.push(job);
159
+ if (job.exclusive)
160
+ break; // at the head: it runs solo
161
+ }
162
+ return __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_take).call(this, (job) => taken.includes(job));
91
163
  }, _Scheduler_take = function _Scheduler_take(pred) {
92
164
  const kept = [];
93
165
  const taken = [];
@@ -97,26 +169,286 @@ _Scheduler_instances = new WeakSet(), _Scheduler_announce = function _Scheduler_
97
169
  if (taken.length > 0)
98
170
  __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_announce).call(this);
99
171
  return taken;
100
- }, _Scheduler_serveTurn =
101
- /** Serve one model's snapshot with bounded concurrency. */
102
- async function _Scheduler_serveTurn(turnId) {
103
- const jobs = __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_take).call(this, (j) => j.modelId === turnId);
104
- let next = 0;
105
- const worker = async () => {
106
- while (next < jobs.length) {
107
- const job = jobs[next];
108
- next += 1;
109
- try {
110
- job.resolve(await job.run());
172
+ }, _Scheduler_warmSessions = function _Scheduler_warmSessions() {
173
+ const warm = new Set();
174
+ for (const job of __classPrivateFieldGet(this, _Scheduler_running, "f"))
175
+ if (job.session)
176
+ warm.add(job.session);
177
+ return warm;
178
+ }, _Scheduler_claimJob = function _Scheduler_claimJob(turnId) {
179
+ if (__classPrivateFieldGet(this, _Scheduler_batch, "f").length === 0)
180
+ return null;
181
+ const warm = __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_warmSessions).call(this);
182
+ const chosen = warm.size > 0
183
+ ? (__classPrivateFieldGet(this, _Scheduler_batch, "f").find((job) => job.session !== null && warm.has(job.session)) ??
184
+ __classPrivateFieldGet(this, _Scheduler_batch, "f")[0])
185
+ : __classPrivateFieldGet(this, _Scheduler_batch, "f")[0];
186
+ __classPrivateFieldGet(this, _Scheduler_batch, "f").splice(__classPrivateFieldGet(this, _Scheduler_batch, "f").indexOf(chosen), 1);
187
+ if (chosen.session)
188
+ this.hotSessions.set(turnId, chosen.session);
189
+ return chosen;
190
+ }, _Scheduler_sampleFreeSlots =
191
+ /**
192
+ * The engine's idle slot count, or null when it cannot be sampled - in which
193
+ * case the static profile count is the best available answer rather than
194
+ * "wait forever". Also carries the free slot ids when the engine reports
195
+ * them, so `#pass` can pin each admitted job to a distinct one.
196
+ */
197
+ async function _Scheduler_sampleFreeSlots() {
198
+ if (!this.freeSlots)
199
+ return null;
200
+ try {
201
+ const free = await this.freeSlots();
202
+ if (free === null || free === undefined || (typeof free === "number" && Number.isNaN(free)))
203
+ return null;
204
+ if (typeof free === "number")
205
+ return { idle: Math.max(0, free) };
206
+ return {
207
+ idle: Math.max(0, free.idle),
208
+ ...(Array.isArray(free.ids) && free.ids.length > 0 ? { ids: free.ids } : {}),
209
+ };
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ }, _Scheduler_pollForSlot = function _Scheduler_pollForSlot() {
215
+ if (__classPrivateFieldGet(this, _Scheduler_slotTimer, "f"))
216
+ return;
217
+ __classPrivateFieldSet(this, _Scheduler_slotTimer, setTimeout(() => {
218
+ __classPrivateFieldSet(this, _Scheduler_slotTimer, null, "f");
219
+ void __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_dispatch).call(this);
220
+ }, this.slotPollMs), "f");
221
+ }, _Scheduler_start =
222
+ /**
223
+ * Start a job and wire its completion back into the dispatcher. The settle
224
+ * callback is what asks for the next decision. `await`ed by the pass: a
225
+ * handoff erase must reach the engine's task queue BEFORE this job's
226
+ * completion is posted (see OWNERSHIP), so the pass yields for the erase.
227
+ */
228
+ async function _Scheduler_start(job) {
229
+ __classPrivateFieldGet(this, _Scheduler_running, "f").add(job);
230
+ if (job.exclusive)
231
+ this.activeJob = job;
232
+ // Name the slot this job is admitted to, from this pass's own sample,
233
+ // before `run()` is invoked. One id per job, never reused: two jobs
234
+ // admitted in the same pass must never be pinned to the same slot.
235
+ if (!job.exclusive && job.onSlotFree) {
236
+ const slotId = __classPrivateFieldGet(this, _Scheduler_freeSlotIds, "f") ? (__classPrivateFieldGet(this, _Scheduler_freeSlotIds, "f").shift() ?? null) : null;
237
+ job.slotId = slotId;
238
+ // OWNERSHIP: the slot now belongs to this job's session. A job may only
239
+ // reuse a slot's KV when it is the same session that last ran there -
240
+ // llama-server keeps a released slot's prompt, so a different session
241
+ // would inherit the previous chat's KV (the cross-chat bleed). Erase the
242
+ // slot - and WAIT for the engine to acknowledge it - before the job's
243
+ // run() reaches the engine, so the handoff lands in the engine's task
244
+ // queue ahead of this completion. Same-session reuse keeps the KV: that
245
+ // is the cache --cache-ram exists to protect, and settling never erases.
246
+ if (slotId !== null) {
247
+ // A slot with NO entry is fresh (just launched, or just relaunched):
248
+ // it holds nothing, so nothing may be erased for it - the first chat
249
+ // to land there is the one the KV is being paid for. A slot WITH an
250
+ // entry holds that session's KV (or a keyless client's, recorded as
251
+ // null): handing it to a DIFFERENT session would inherit that KV, so
252
+ // the erase is owed. The entry's mere existence is the distinction -
253
+ // `get() ?? null` collapses it and erases fresh slots.
254
+ const hasPrevious = this.slotOwners.has(slotId);
255
+ const previous = this.slotOwners.get(slotId) ?? null;
256
+ if (hasPrevious && previous !== job.session) {
257
+ await __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_eraseFor).call(this, slotId, previous, job.session);
258
+ }
259
+ this.slotOwners.set(slotId, job.session);
260
+ }
261
+ try {
262
+ job.onSlotFree(slotId);
263
+ }
264
+ catch {
265
+ // Slot attribution must never fail the job it belongs to.
266
+ }
267
+ }
268
+ __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_announce).call(this);
269
+ void (async () => {
270
+ try {
271
+ job.onStart?.();
272
+ job.resolve(await job.run());
273
+ }
274
+ catch (error) {
275
+ job.reject(error);
276
+ }
277
+ finally {
278
+ __classPrivateFieldGet(this, _Scheduler_running, "f").delete(job);
279
+ if (this.activeJob === job)
280
+ this.activeJob = null;
281
+ // OWNERSHIP: settling does NOT erase and does NOT clear the owner entry.
282
+ // The engine keeps a released slot's KV indefinitely (nothing in our
283
+ // configuration clears or parks it - see OWNERSHIP in the header), and
284
+ // that is exactly what this job's session wants back on its next turn:
285
+ // the chat's KV, paid for once, reused on every following request.
286
+ // Erasing here would hand the same chat its own re-prefill cost, and a
287
+ // DIFFERENT chat is protected the moment it is admitted, when the
288
+ // owner mismatch is known for sure. So the slot keeps its KV and its
289
+ // owner until the next admission - whoever that is - decides otherwise.
290
+ __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_announce).call(this);
291
+ void __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_dispatch).call(this);
292
+ }
293
+ })();
294
+ }, _Scheduler_eraseFor =
295
+ /**
296
+ * Erase one slot's KV before it is handed to a different owner (see
297
+ * OWNERSHIP). `from` is the session whose KV the slot currently holds
298
+ * (null for a keyless previous owner), `to` is the session about to run on
299
+ * it. Resolves once the engine acknowledges the erase, so the caller can
300
+ * post the new completion only after the clean state is guaranteed to sit
301
+ * in the engine's task queue first. Never throws or rejects: a failure
302
+ * degrades to the old (bleedy) behavior but must not fail the completion it
303
+ * protects.
304
+ */
305
+ async function _Scheduler_eraseFor(slotId, from, to) {
306
+ if (!this.eraseSlot)
307
+ return;
308
+ try {
309
+ this.logger?.(`erasing slot ${slotId} before handoff${from ? ` from ${from}` : ""} to ${to}`);
310
+ await this.eraseSlot(slotId);
311
+ }
312
+ catch (error) {
313
+ this.logger?.(`slot ${slotId} erase failed: ${describeError(error)}`);
314
+ }
315
+ }, _Scheduler_dispatch =
316
+ /**
317
+ * Ask for a scheduling decision. Safe to call from anywhere, any number of
318
+ * times: overlapping calls collapse into one more pass after the current one,
319
+ * so the state a pass reads never moves underneath it.
320
+ */
321
+ async function _Scheduler_dispatch() {
322
+ if (__classPrivateFieldGet(this, _Scheduler_busy, "f")) {
323
+ __classPrivateFieldSet(this, _Scheduler_dirty, true, "f");
324
+ return;
325
+ }
326
+ __classPrivateFieldSet(this, _Scheduler_busy, true, "f");
327
+ try {
328
+ do {
329
+ __classPrivateFieldSet(this, _Scheduler_dirty, false, "f");
330
+ await __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_pass).call(this);
331
+ } while (__classPrivateFieldGet(this, _Scheduler_dirty, "f"));
332
+ }
333
+ finally {
334
+ __classPrivateFieldSet(this, _Scheduler_busy, false, "f");
335
+ }
336
+ }, _Scheduler_resetSlots = function _Scheduler_resetSlots() {
337
+ this.forgetSlots();
338
+ __classPrivateFieldSet(this, _Scheduler_freeSlotIds, null, "f");
339
+ }, _Scheduler_pass =
340
+ /**
341
+ * One decision pass: start as many jobs as residency, exclusivity, the turn
342
+ * boundary and capacity allow, then return. Each iteration re-derives
343
+ * everything, so there is no state to keep consistent between them.
344
+ */
345
+ async function _Scheduler_pass() {
346
+ // The engine's idle count, sampled at most once per pass, plus how many
347
+ // jobs this pass has started against it (the sample cannot see those yet).
348
+ let measured = null;
349
+ let sampled = false;
350
+ let startedHere = 0;
351
+ // Distinct slot ids this pass may pin, one per admitted job. Refilled from
352
+ // the sample the first time capacity is checked; consumed in `#start`.
353
+ __classPrivateFieldSet(this, _Scheduler_freeSlotIds, null, "f");
354
+ for (;;) {
355
+ // An exclusive operation owns the engine alone.
356
+ if (this.activeJob !== null)
357
+ return;
358
+ // 1. Between turns: a turn may only begin on a drained engine, because
359
+ // it may need a different model resident.
360
+ if (__classPrivateFieldGet(this, _Scheduler_turnId, "f") === null) {
361
+ if (__classPrivateFieldGet(this, _Scheduler_running, "f").size > 0)
362
+ return; // its settle callback re-dispatches
363
+ if (this.queue.length === 0)
364
+ return; // idle
365
+ // Prefer a model other than the one that just ran, so two sides
366
+ // alternate; fall back to the head when only one model is waiting.
367
+ const pick = this.queue.find((job) => job.modelId !== this.lastTurnId) ?? this.queue[0];
368
+ if (this.loadedId !== pick.modelId) {
369
+ try {
370
+ this.logger?.(`switching to ${pick.model.displayName}`);
371
+ await this.loadModel(pick.model);
372
+ }
373
+ catch (error) {
374
+ // The model would not load - fail exactly its queued jobs, move on.
375
+ for (const job of __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_take).call(this, (j) => j.modelId === pick.modelId))
376
+ job.reject(error);
377
+ continue;
378
+ }
379
+ // A relaunched engine has all its slots back; the old sample is void,
380
+ // and slot ids do not survive the relaunch, so the owners that named
381
+ // them are void too - a stale entry would make the next admission
382
+ // erase a FRESH slot (an unnecessary re-prefill) or mistake a keyless
383
+ // job for its owner.
384
+ sampled = false;
385
+ startedHere = 0;
386
+ __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_resetSlots).call(this);
111
387
  }
112
- catch (error) {
113
- job.reject(error);
388
+ __classPrivateFieldSet(this, _Scheduler_turnId, pick.modelId, "f");
389
+ this.lastTurnId = pick.modelId;
390
+ __classPrivateFieldSet(this, _Scheduler_batch, __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_takeTurn).call(this, pick.modelId), "f");
391
+ __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_announce).call(this);
392
+ }
393
+ const turnId = __classPrivateFieldGet(this, _Scheduler_turnId, "f");
394
+ // 2. Batch drained: keep the turn open by absorbing the queue head when
395
+ // it belongs to this turn. This is what lets a steady stream of chats
396
+ // each hold their own slot instead of trading one.
397
+ if (__classPrivateFieldGet(this, _Scheduler_batch, "f").length === 0) {
398
+ const head = this.queue[0];
399
+ if (head !== undefined && !head.exclusive && head.modelId === turnId) {
400
+ __classPrivateFieldSet(this, _Scheduler_batch, __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_take).call(this, (job) => job === head), "f");
401
+ }
402
+ else {
403
+ // Boundary: another model's job, an exclusive operation, or nothing.
404
+ // The turn ends as soon as its own jobs have drained.
405
+ if (__classPrivateFieldGet(this, _Scheduler_running, "f").size > 0)
406
+ return;
407
+ __classPrivateFieldSet(this, _Scheduler_turnId, null, "f");
408
+ __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_announce).call(this);
409
+ if (this.queue.length === 0)
410
+ return;
411
+ continue; // pick the next turn
114
412
  }
115
413
  }
116
- };
117
- const pool = [];
118
- for (let i = 0; i < Math.min(this.concurrency, jobs.length); i += 1)
119
- pool.push(worker());
120
- await Promise.all(pool);
414
+ // 3. An exclusive operation runs alone, on a drained engine. (Awaited:
415
+ // `#start` may pause on a handoff erase, and the pass must not read or
416
+ // mutate the state a started job depends on while it is in flight.)
417
+ if (__classPrivateFieldGet(this, _Scheduler_batch, "f").length === 1 && __classPrivateFieldGet(this, _Scheduler_batch, "f")[0].exclusive) {
418
+ if (__classPrivateFieldGet(this, _Scheduler_running, "f").size > 0)
419
+ return;
420
+ await __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_start).call(this, __classPrivateFieldGet(this, _Scheduler_batch, "f").shift());
421
+ return;
422
+ }
423
+ // 4. Capacity. The ceiling is the KV pool we own; the measurement is
424
+ // there to notice slots taken by traffic we did not schedule. They
425
+ // are combined, never both subtracted - see CAPACITY in the header.
426
+ if (!sampled) {
427
+ measured = await __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_sampleFreeSlots).call(this);
428
+ sampled = true;
429
+ // Drop any id a running job already holds. That job may have been
430
+ // admitted only moments ago and not yet reached the engine, so this
431
+ // sample can still see its slot idle; handing the same id out twice
432
+ // would pin two requests to one slot (see `QueuedJob.slotId`).
433
+ const held = new Set();
434
+ for (const job of __classPrivateFieldGet(this, _Scheduler_running, "f"))
435
+ if (job.slotId !== null)
436
+ held.add(job.slotId);
437
+ const ids = measured?.ids?.filter((id) => !held.has(id)) ?? null;
438
+ __classPrivateFieldSet(this, _Scheduler_freeSlotIds, ids && ids.length > 0 ? ids : null, "f");
439
+ }
440
+ const room = Math.min(this.concurrency - __classPrivateFieldGet(this, _Scheduler_running, "f").size, measured === null ? Number.POSITIVE_INFINITY : measured.idle - startedHere);
441
+ if (room < 1) {
442
+ if (__classPrivateFieldGet(this, _Scheduler_running, "f").size === 0)
443
+ __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_pollForSlot).call(this);
444
+ return;
445
+ }
446
+ // 5. Start the next job. Affinity decides which of the batch goes first.
447
+ const job = __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_claimJob).call(this, turnId);
448
+ if (!job)
449
+ return;
450
+ startedHere += 1;
451
+ await __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_start).call(this, job);
452
+ }
121
453
  };
122
454
  //# sourceMappingURL=scheduler.js.map
@@ -1,7 +1,11 @@
1
1
  import http from "node:http";
2
2
  import type { BrainConfig } from "../config/schema.js";
3
3
  import type { Model } from "../types.js";
4
+ import { type HostJob } from "./host-api.js";
4
5
  import { Supervisor } from "./supervisor.js";
6
+ /** Model pulls only write the model store, so independent entries can transfer together. */
7
+ export declare function canRunAlongsideModelPull(kind: HostJob["kind"]): boolean;
8
+ export declare function componentOnlyArgs(args: string[], components: string[]): string[];
5
9
  /**
6
10
  * Pull the client's presented key from the request. Accepts, in order, an
7
11
  * `Authorization: Bearer …`, an `x-api-key` (OpenAI/Anthropic convention, and