@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
|
@@ -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
|
|
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
|
-
*
|
|
33
|
-
*
|
|
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({
|
|
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.
|
|
101
|
+
queueMicrotask(() => void __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_dispatch).call(this));
|
|
40
102
|
});
|
|
41
103
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
package/dist/service/serve.d.ts
CHANGED
|
@@ -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
|