@otto-code/brain 0.8.9 → 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
@@ -1,25 +1,80 @@
1
1
  /**
2
- * Cooperative single-GPU scheduler with per-model concurrency.
2
+ * Cooperative single-GPU scheduler: one resident model, many concurrent chats.
3
3
  *
4
- * Only one model is resident at a time. Completion requests are queued rather
5
- * than refused for "wrong / no model loaded".
4
+ * There is exactly one place a job is ever started - `#dispatch`. Every event
5
+ * that could change the answer (a submit, a job settling, a model finishing its
6
+ * load, a slot poll) calls it again, and it re-derives the whole decision from
7
+ * scratch. There is no worker pool, no parked promise, no wake generation and
8
+ * no claim lock: the dispatcher is single-threaded by construction (`#busy`),
9
+ * so the state it reads cannot move underneath it.
6
10
  *
7
- * - Requests for the resident model run concurrently, up to that model's
8
- * `parallelSlots` (the same number of sequence slots llama-server was
9
- * launched with - sending more would only queue inside llama-server). Extra
10
- * same-model requests wait for a free slot; they never trigger a load.
11
- * - Requests for a *different* model wait for a model switch. When the resident
12
- * model's current batch drains, the scheduler switches and serves the other
13
- * model's batch - so two clients wanting different models share the GPU by
14
- * taking turns.
11
+ * The variables it arbitrates, in the order it applies them:
15
12
  *
16
- * Fairness: after a model finishes a turn, the next turn prefers a *different*
17
- * model when one is waiting, so a steady stream for model A cannot starve model
18
- * B. A turn is a snapshot: requests that arrive for a model mid-turn wait for
19
- * its next turn.
13
+ * 1. RESIDENCY. Only one model is resident, so `#running` never mixes models.
14
+ * A switch happens only from a fully drained engine. Nothing that reaches
15
+ * the scheduler is ever refused for "wrong model loaded" - it is queued
16
+ * until its model is resident. Whether a switch is *allowed* at all is
17
+ * decided upstream by `decideModelGate`: with `config.lockModel` on, the
18
+ * host serves one pinned model and a request naming another is refused
19
+ * there with a 409, so the scheduler never sees it.
20
+ *
21
+ * 2. EXCLUSIVITY. Calibrate, sweep and benchmark own the engine alone: the
22
+ * engine drains before one starts, and nothing is admitted while it runs.
23
+ *
24
+ * 3. THE TURN, and what may join it. A turn takes every queued job of its
25
+ * model up to the first exclusive operation. When that batch drains the
26
+ * turn stays OPEN: a same-model job arriving a moment later joins it and
27
+ * takes a free slot immediately. That is the difference between two chats
28
+ * each holding their own slot and two chats trading one slot back and
29
+ * forth - the latter is what a closed per-turn batch produces, because
30
+ * agentic traffic arrives one request per chat per turn and never as a
31
+ * burst.
32
+ *
33
+ * 4. FAIRNESS. A turn stops absorbing the moment the queue head is another
34
+ * model's job or an exclusive operation. Since the queue is FIFO, "another
35
+ * model is waiting" means it is at the head, so a steady stream for model
36
+ * A cannot starve model B: B waits exactly as long as A's already-running
37
+ * jobs take, then the turn retires and B is picked (preferring a model
38
+ * other than the one that just ran).
39
+ *
40
+ * 5. CAPACITY. `parallelSlots` is the number of sequence slots llama-server
41
+ * was launched with and therefore the KV pool we own; `#running.size`
42
+ * against it is the hard bound. The measured free-slot count (llama-server's
43
+ * own `/slots`) is the second bound, and it exists to notice slots taken by
44
+ * traffic Otto did not schedule, or an engine mid-eviction. The two are
45
+ * combined as `min(ceiling - running, measured)` and never both subtracted:
46
+ * the engine's idle count already excludes our running jobs, so subtracting
47
+ * them from it a second time leaves zero capacity with a single chat live
48
+ * and silently serializes every other chat behind it.
49
+ *
50
+ * Ordering within a turn is session-affine: a job whose session already holds a
51
+ * slot goes first, because that chat's KV state is what llama-server's
52
+ * longest-common-prefix slot selection just filled, so it pays no re-prefill.
53
+ * Session identity is the standard `prompt_cache_key`; clients that omit it get
54
+ * plain FIFO.
55
+ *
56
+ * 6. OWNERSHIP. A slot's KV belongs to the chat that last ran on it.
57
+ * llama-server never clears a released slot's prompt, so a slot handed to a
58
+ * different chat would still hold the previous chat's KV - and that is
59
+ * exactly the cross-chat bleed users see in thinking blocks. The scheduler
60
+ * therefore tracks `slotId -> session` for every slot it names (the
61
+ * `prompt_cache_key` that owned it, or `null` for keyless jobs) and erases
62
+ * the slot before handoff to a different owner. Same session reusing its
63
+ * own slot keeps its KV (that is the cache the whole point of `--cache-ram`
64
+ * is to protect). The eraser is injected, because the engine endpoint is a
65
+ * transport detail the scheduler stays agnostic of. The map is wiped when
66
+ * the engine reloads a model: slot ids do not survive a switch, and a
67
+ * fresh engine has no stale KV.
68
+ *
69
+ * The erase is awaited BEFORE the job's run() reaches the engine: the
70
+ * engine runs tasks in arrival order, so the erase must land in its queue
71
+ * ahead of the completion that follows. Firing the erase and the
72
+ * completion back-to-back as independent requests would let the engine see
73
+ * the completion first, run it on the dirty slot, and only then erase the
74
+ * KV the completion just produced - the fix inverting into the bug.
20
75
  *
21
76
  * The scheduler is transport-agnostic: a job is a resolved catalog model plus a
22
- * `run()` that does the proxying, which keeps the turn logic unit-testable.
77
+ * `run()` that does the proxying, which keeps the logic unit-testable.
23
78
  */
24
79
  import type { Model } from "../types.js";
25
80
  import type { Profile } from "../config/schema.js";
@@ -29,6 +84,37 @@ export interface SchedulerSupervisor {
29
84
  model: Model | null;
30
85
  profile: Profile | null;
31
86
  }
87
+ /**
88
+ * The answer to a live slot measurement: how many sequence slots are free, and
89
+ * optionally WHICH ones. `idle` drives admission (see CAPACITY); `ids` lets
90
+ * `#pass` hand a distinct slot id to each job it admits, so the router can pin
91
+ * a completion to the exact slot this sample saw free. `ids` is absent when the
92
+ * engine reports a count but no per-slot rows - then admission still works, but
93
+ * no honest pin can be named and the affected requests run unpinned.
94
+ */
95
+ export interface SlotMeasurement {
96
+ idle: number;
97
+ ids?: number[];
98
+ }
99
+ /**
100
+ * Erase one engine slot's retained KV state before the next task may land on
101
+ * it. This is the fix for the cross-chat KV bleed (see OWNERSHIP below):
102
+ * llama.cpp never clears a slot's prompt on release, so a slot that served one
103
+ * chat hands that chat's KV to whoever is pinned to the slot next. The caller
104
+ * (the router) performs the engine-side erase; the scheduler only decides WHEN
105
+ * a handoff happens and reports it, because only it knows which session was
106
+ * admitted to which slot.
107
+ *
108
+ * The promise resolves (never rejects) once the engine has ACKNOWLEDGED the
109
+ * erase. That acknowledgment matters: the engine runs tasks in arrival order,
110
+ * so the erase must sit in its queue ahead of the completion the scheduler is
111
+ * about to post. The scheduler awaits the promise before `run()` reaches the
112
+ * engine, which is what keeps the order honest across the HTTP boundary.
113
+ *
114
+ * Must never throw or reject: an erase failure degrades to the old (bleedy)
115
+ * behavior but must never fail the completion that is about to run.
116
+ */
117
+ export type SlotEraser = (slotId: number) => Promise<void>;
32
118
  export interface SchedulerOptions {
33
119
  supervisor: SchedulerSupervisor;
34
120
  loadModel: (model: Model) => Promise<void>;
@@ -40,11 +126,89 @@ export interface SchedulerOptions {
40
126
  * the moment it becomes true rather than up to a poll later.
41
127
  */
42
128
  onChange?: (() => void) | null;
129
+ /**
130
+ * Live slot measurement: how many sequence slots llama-server actually has
131
+ * free right now, or null when unknown (server not ready, sample failed).
132
+ * See CAPACITY in the file header for how it combines with `parallelSlots`.
133
+ * Absent means "trust the profile count".
134
+ *
135
+ * May also answer with the free slots NAMED, not just counted:
136
+ * `{ idle, ids }` (the engine's idle slot ids from `/slots`). The count is
137
+ * what admission needs; the ids are what `onSlotFree` hands to admitted
138
+ * completions so the router can pin each one to the slot this very sample
139
+ * saw free. A plain number still works - it just yields no pin data.
140
+ */
141
+ freeSlots?: (() => Promise<SlotMeasurement | number | null> | SlotMeasurement | number | null) | null;
142
+ /**
143
+ * How long to wait before re-checking the engine when capacity is zero and
144
+ * no job of ours is running - the only state where nothing else will wake
145
+ * the dispatcher.
146
+ */
147
+ slotPollMs?: number;
148
+ /**
149
+ * Erase an engine slot's KV before it is handed to a different chat (see
150
+ * OWNERSHIP). Injected rather than built in: the engine endpoint is a
151
+ * transport detail, and its availability is runtime-dependent (the
152
+ * llama-server build must support `POST /slots?action=erase` - it does not
153
+ * on a server launched without `--slot-save-path`). Absent (null) means
154
+ * ownership is tracked and reported but nothing is erased - the old
155
+ * behavior, for runtimes where the engine cannot wipe a slot.
156
+ */
157
+ eraseSlot?: SlotEraser | null;
158
+ }
159
+ /** Work kinds sharing the single resident model. Operations own a full turn. */
160
+ export type SchedulerJobKind = "completion" | "calibrate" | "sweep" | "benchmark";
161
+ export interface SchedulerSubmitOptions {
162
+ kind?: SchedulerJobKind;
163
+ /** An operation never shares its model turn with inference. */
164
+ exclusive?: boolean;
165
+ /** Called exactly when this job starts running, before `run()` is awaited. */
166
+ onStart?: (() => void) | null;
167
+ /**
168
+ * The chat's stable identity (its `prompt_cache_key`). Jobs from a session
169
+ * that already holds a slot run next in line for their model, so a resident
170
+ * chat's KV cache is reused instead of evicted by a different chat. Absent
171
+ * (third-party clients) means no affinity - plain FIFO.
172
+ */
173
+ session?: string | null;
174
+ /**
175
+ * Called exactly once, when this job is admitted to a slot - i.e. the moment
176
+ * the scheduler's own free-slot measurement just counted it. The argument is
177
+ * the engine slot id the job may pin to, or null when no slot can be named
178
+ * honestly (sample failed, engine reports no per-slot rows, or the engine's
179
+ * slot pool is fully busy).
180
+ *
181
+ * Why the scheduler, not the job, names the slot: a job that samples `/slots`
182
+ * itself at dispatch time can race another job admitted in the same pass -
183
+ * neither has reached the engine yet, so both see the same slot free and both
184
+ * pin it, and the engine's busy-pinned-slot deferral turns the pair back into
185
+ * a serial queue. The pin must be drawn from the very measurement that
186
+ * admitted the job, one distinct id per job, so the ids it hands out are
187
+ * exactly the slots the engine still has free at the moment of admission.
188
+ *
189
+ * Exclusive operations are never called: they run alone on a drained engine
190
+ * and their attribution is not per-slot.
191
+ */
192
+ onSlotFree?: ((slotId: number | null) => void) | null;
43
193
  }
44
- /** A queued completion request bound to a resolved catalog model. */
194
+ /** A queued request or host operation bound to a resolved catalog model. */
45
195
  export interface QueuedJob {
46
196
  modelId: string;
47
197
  model: Model;
198
+ kind: SchedulerJobKind;
199
+ exclusive: boolean;
200
+ session: string | null;
201
+ onStart: (() => void) | null;
202
+ onSlotFree: ((slotId: number | null) => void) | null;
203
+ /**
204
+ * The engine slot this job was pinned to at admission, or null when none was
205
+ * named. Kept on the job so a later pass can exclude it from the ids it hands
206
+ * out: a job admitted moments ago may not have reached llama-server yet, so
207
+ * the next `/slots` sample can still report its slot idle and offer it to a
208
+ * second job. Both would then pin the same slot, and the engine's defer-when-
209
+ * busy behavior would serialize the pair onto it while another slot sat empty.
210
+ */
211
+ slotId: number | null;
48
212
  run: () => Promise<unknown>;
49
213
  resolve: (value: unknown) => void;
50
214
  reject: (error: unknown) => void;
@@ -52,30 +216,69 @@ export interface QueuedJob {
52
216
  export interface SchedulerStats {
53
217
  queued: number;
54
218
  waiting: Record<string, number>;
219
+ /** Stable ids for host surfaces. Display names are not scheduler keys. */
220
+ waitingModelIds: Record<string, number>;
55
221
  lastTurn: string | null;
222
+ active: {
223
+ modelId: string;
224
+ kind: Exclude<SchedulerJobKind, "completion">;
225
+ } | null;
56
226
  }
57
227
  export declare class Scheduler {
58
228
  #private;
59
229
  supervisor: SchedulerSupervisor;
60
230
  loadModel: (model: Model) => Promise<void>;
61
231
  logger: ((message: string) => void) | null;
232
+ /** Submitted, not yet claimed by a turn. */
62
233
  queue: QueuedJob[];
63
234
  lastTurnId: string | null;
64
- pumping: boolean;
235
+ /**
236
+ * Per model: the session whose jobs most recently filled its slots. That
237
+ * session's KV state is the one llama-server's LCP selection will match
238
+ * against, so its queued jobs go first on the next dispatch.
239
+ */
240
+ hotSessions: Map<string, string>;
241
+ /** The exclusive operation in flight, if any. Reported by `stats()`. */
242
+ activeJob: QueuedJob | null;
65
243
  onChange: (() => void) | null;
66
- constructor({ supervisor, loadModel, logger, onChange }: SchedulerOptions);
244
+ freeSlots: SchedulerOptions["freeSlots"];
245
+ slotPollMs: number;
246
+ eraseSlot: SlotEraser | null;
247
+ /**
248
+ * Engine slot -> the session that last ran on it (see OWNERSHIP). The value
249
+ * is null for keyless jobs: a keyless job cannot prove ownership of anything
250
+ * after it settles, so the next keyed chat landing on that slot is treated as
251
+ * a handoff and the slot is erased for it. Wiped wholesale whenever the
252
+ * engine reloads a model - slot ids do not survive the relaunch.
253
+ */
254
+ slotOwners: Map<number, string | null>;
255
+ constructor({ supervisor, loadModel, logger, onChange, freeSlots, slotPollMs, eraseSlot, }: SchedulerOptions);
67
256
  /** Id of the model that is actually loaded and ready, or null. */
68
257
  get loadedId(): string | null;
69
- /** How many requests may run at once against the resident model. */
258
+ /** How many requests may run at once against the resident model (static ceiling). */
70
259
  get concurrency(): number;
71
260
  /**
72
261
  * Queue a job for an already-resolved catalog model. `run` is invoked once
73
262
  * that model is the resident one; the returned promise settles when run does.
74
- * Pumping is deferred a microtask so a burst of requests submitted together
75
- * share one turn rather than the first one snapshotting a turn by itself.
263
+ * Dispatch is deferred a microtask so a burst of requests submitted together
264
+ * shares one turn rather than the first one taking a turn by itself.
265
+ */
266
+ submit(model: Model, run: () => Promise<unknown>, { kind, exclusive, onStart, session, onSlotFree, }?: SchedulerSubmitOptions): Promise<unknown>;
267
+ /**
268
+ * Drop every recorded slot owner. The engine's slots do not survive a model
269
+ * (re)launch, so their owners do not either - a stale entry would make the
270
+ * next admission think a FRESH slot still holds a previous chat's KV and
271
+ * erase it (an unnecessary re-prefill), or, worse, let a keyless job be
272
+ * mistaken for the owner of a slot it is not.
273
+ *
274
+ * Called from two places: `#pass` clears the owners the moment a turn begins
275
+ * on a different model than the one it last served (a model switch, where
276
+ * the scheduler itself sees the relaunch), and the router calls it on the
277
+ * supervisor's `starting` state - the relaunch that keeps the SAME model
278
+ * resident (a live profile edit), where the turn never changes and only the
279
+ * supervisor says the slots are gone. Both paths are idempotent.
76
280
  */
77
- submit(model: Model, run: () => Promise<unknown>): Promise<unknown>;
78
- pump(): Promise<void>;
281
+ forgetSlots(): void;
79
282
  /** Queue snapshot for the status endpoint / UI. */
80
283
  stats(): SchedulerStats;
81
284
  }