@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.
- package/dist/commands/calibrate.js +9 -0
- package/dist/commands/catalog.d.ts +1 -0
- package/dist/commands/catalog.js +1 -0
- package/dist/commands/pull.d.ts +1 -0
- package/dist/commands/pull.js +12 -3
- package/dist/commands/search.d.ts +1 -0
- package/dist/commands/search.js +12 -2
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +1 -1
- package/dist/config/profile-edit.d.ts +88 -1
- package/dist/config/profile-edit.js +280 -29
- package/dist/config/profiles.js +16 -0
- package/dist/config/schema.d.ts +608 -0
- package/dist/config/schema.js +58 -0
- package/dist/config/store.js +7 -4
- package/dist/gguf.d.ts +7 -0
- package/dist/gguf.js +15 -2
- package/dist/models/download.d.ts +1 -1
- package/dist/models/download.js +2 -2
- package/dist/models/enrich.d.ts +6 -0
- package/dist/models/enrich.js +27 -1
- package/dist/models/index.d.ts +1 -1
- package/dist/models/index.js +4 -3
- package/dist/ops/calibrate.d.ts +38 -3
- package/dist/ops/calibrate.js +68 -19
- package/dist/ops/report.js +51 -1
- package/dist/ops/results.d.ts +57 -11
- package/dist/ops/results.js +75 -10
- package/dist/ops/sweep.d.ts +38 -1
- package/dist/ops/sweep.js +61 -10
- package/dist/runtime/args.d.ts +15 -2
- package/dist/runtime/args.js +60 -5
- package/dist/runtime/managed.js +2 -2
- package/dist/service/activity.d.ts +19 -0
- package/dist/service/activity.js +47 -4
- package/dist/service/host-api.d.ts +25 -4
- package/dist/service/host-api.js +82 -16
- package/dist/service/log-format.d.ts +18 -0
- package/dist/service/log-format.js +32 -0
- package/dist/service/router.d.ts +70 -2
- package/dist/service/router.js +219 -21
- package/dist/service/run-log.d.ts +6 -1
- package/dist/service/run-log.js +46 -4
- package/dist/service/scheduler.d.ts +227 -24
- package/dist/service/scheduler.js +395 -63
- package/dist/service/serve.d.ts +4 -0
- package/dist/service/serve.js +302 -117
- package/dist/service/status-events.d.ts +14 -1
- package/dist/service/status-events.js +111 -12
- package/dist/service/supervisor.d.ts +9 -7
- package/dist/service/supervisor.js +37 -12
- package/dist/sysmon.d.ts +15 -0
- package/dist/sysmon.js +56 -9
- package/dist/tui/app.d.ts +8 -2
- package/dist/tui/app.js +65 -17
- package/dist/types.d.ts +18 -0
- package/dist/vram.d.ts +37 -0
- package/dist/vram.js +57 -18
- package/package.json +1 -1
|
@@ -1,25 +1,80 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cooperative single-GPU scheduler
|
|
2
|
+
* Cooperative single-GPU scheduler: one resident model, many concurrent chats.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* its
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
75
|
-
*
|
|
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
|
-
|
|
78
|
-
pump(): Promise<void>;
|
|
281
|
+
forgetSlots(): void;
|
|
79
282
|
/** Queue snapshot for the status endpoint / UI. */
|
|
80
283
|
stats(): SchedulerStats;
|
|
81
284
|
}
|