@otto-code/brain 0.8.12 → 0.8.14
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/bench.js +2 -2
- package/dist/commands/calibrate.js +8 -8
- package/dist/commands/pull.d.ts +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +1 -1
- package/dist/config/profile-edit.js +16 -16
- package/dist/config/profiles.d.ts +19 -3
- package/dist/config/profiles.js +36 -4
- package/dist/config/schema.d.ts +8 -0
- package/dist/config/schema.js +7 -3
- package/dist/ops/archive.d.ts +14 -1
- package/dist/ops/archive.js +9 -5
- package/dist/ops/results.d.ts +21 -1
- package/dist/ops/results.js +10 -5
- package/dist/service/activity.d.ts +95 -14
- package/dist/service/activity.js +206 -78
- package/dist/service/host-api.d.ts +7 -4
- package/dist/service/host-api.js +31 -16
- package/dist/service/process-pool.d.ts +45 -0
- package/dist/service/process-pool.js +271 -0
- package/dist/service/router.d.ts +7 -4
- package/dist/service/router.js +123 -59
- package/dist/service/scheduler.d.ts +41 -10
- package/dist/service/scheduler.js +49 -3
- package/dist/service/serve.js +107 -58
- package/dist/service/status-events.js +1 -0
- package/dist/service/supervisor.js +2 -2
- package/dist/tui/app.js +18 -9
- package/package.json +1 -1
package/dist/service/router.js
CHANGED
|
@@ -205,30 +205,39 @@ function resolveCatalog(getCatalog) {
|
|
|
205
205
|
* the supervisor is running marked 'loaded'. Falls back to just the running
|
|
206
206
|
* model when no catalog provider is wired in.
|
|
207
207
|
*/
|
|
208
|
-
export function buildModelList(supervisor, getCatalog) {
|
|
209
|
-
const
|
|
208
|
+
export function buildModelList(supervisor, getCatalog, scheduler = null) {
|
|
209
|
+
const supervisors = scheduler && typeof scheduler.supervisors === "function"
|
|
210
|
+
? scheduler.supervisors()
|
|
211
|
+
: [supervisor];
|
|
212
|
+
const residentFor = (modelId) => supervisors.find((candidate) => candidate.model?.id === modelId && candidate.state !== "stopped") ?? null;
|
|
210
213
|
const stateOf = (model) => {
|
|
211
|
-
|
|
214
|
+
const resident = residentFor(model.id);
|
|
215
|
+
if (!resident)
|
|
212
216
|
return "not-loaded";
|
|
213
|
-
if (
|
|
217
|
+
if (resident.state === "ready")
|
|
214
218
|
return "loaded";
|
|
215
|
-
if (
|
|
219
|
+
if (resident.state === "starting")
|
|
216
220
|
return "loading";
|
|
217
221
|
return "not-loaded";
|
|
218
222
|
};
|
|
219
223
|
let catalog = resolveCatalog(getCatalog);
|
|
220
224
|
// Guarantee the running model appears even if the snapshot predates it.
|
|
221
|
-
|
|
222
|
-
catalog
|
|
225
|
+
for (const resident of supervisors) {
|
|
226
|
+
if (resident.model && !catalog.some((model) => model.id === resident.model?.id)) {
|
|
227
|
+
catalog.unshift(resident.model);
|
|
228
|
+
}
|
|
223
229
|
}
|
|
224
|
-
return catalog.map((model) =>
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
230
|
+
return catalog.map((model) => {
|
|
231
|
+
const resident = residentFor(model.id);
|
|
232
|
+
return describeModel(model, {
|
|
233
|
+
state: stateOf(model),
|
|
234
|
+
profile: resident?.profile ?? null,
|
|
235
|
+
createdAt: resident?.startedAt ?? null,
|
|
236
|
+
});
|
|
237
|
+
});
|
|
229
238
|
}
|
|
230
239
|
/** Handle the model-discovery endpoints ourselves; returns true if it did. */
|
|
231
|
-
function handleModelsRoute(req, res, supervisor, getCatalog) {
|
|
240
|
+
function handleModelsRoute(req, res, supervisor, getCatalog, scheduler = null) {
|
|
232
241
|
if (req.method !== "GET")
|
|
233
242
|
return false;
|
|
234
243
|
const url = (req.url || "").split("?")[0];
|
|
@@ -238,7 +247,7 @@ function handleModelsRoute(req, res, supervisor, getCatalog) {
|
|
|
238
247
|
: null;
|
|
239
248
|
if (!isList && single === null)
|
|
240
249
|
return false;
|
|
241
|
-
const list = buildModelList(supervisor, getCatalog);
|
|
250
|
+
const list = buildModelList(supervisor, getCatalog, scheduler);
|
|
242
251
|
let payload;
|
|
243
252
|
if (single !== null) {
|
|
244
253
|
const entry = list.find((e) => e.id === single);
|
|
@@ -264,16 +273,6 @@ function handleModelsRoute(req, res, supervisor, getCatalog) {
|
|
|
264
273
|
res.end(body);
|
|
265
274
|
return true;
|
|
266
275
|
}
|
|
267
|
-
/**
|
|
268
|
-
* Correlates the chunks of one proxied stream for the reasoning tracker. A
|
|
269
|
-
* counter rather than a uuid: it never leaves the process and only has to be
|
|
270
|
-
* unique among the handful of streams in flight at once.
|
|
271
|
-
*/
|
|
272
|
-
let streamCounter = 0;
|
|
273
|
-
function nextStreamId() {
|
|
274
|
-
streamCounter += 1;
|
|
275
|
-
return `s${streamCounter}`;
|
|
276
|
-
}
|
|
277
276
|
/**
|
|
278
277
|
* The reasoning tracker is module-scoped rather than per-router because both
|
|
279
278
|
* proxy paths need it and `proxyBuffered` is a free function. One service
|
|
@@ -429,12 +428,16 @@ function proxyBuffered({ agent, model, supervisor, telemetry, logger, req, res,
|
|
|
429
428
|
const slotId = slot ?? null;
|
|
430
429
|
return new Promise((resolve) => {
|
|
431
430
|
let settled = false;
|
|
432
|
-
|
|
431
|
+
// Opened before anything can fail, so every exit below has a lease to
|
|
432
|
+
// release. Releasing is terminal and idempotent, which is what makes it
|
|
433
|
+
// safe to call `done()` from all six paths that can end this request
|
|
434
|
+
// without any of them having to know whether another got there first.
|
|
435
|
+
const lease = reasoning?.begin() ?? null;
|
|
433
436
|
const done = () => {
|
|
434
|
-
// Always release the
|
|
435
|
-
//
|
|
437
|
+
// Always release the stage, including on the error and abort paths: a
|
|
438
|
+
// stream that dies mid-thought would otherwise pin the rail on
|
|
436
439
|
// "thinking" until the service restarts.
|
|
437
|
-
|
|
440
|
+
lease?.end();
|
|
438
441
|
if (!settled) {
|
|
439
442
|
settled = true;
|
|
440
443
|
resolve();
|
|
@@ -470,8 +473,13 @@ function proxyBuffered({ agent, model, supervisor, telemetry, logger, req, res,
|
|
|
470
473
|
res.writeHead(upstreamRes.statusCode ?? 502, outHeaders);
|
|
471
474
|
const isStream = String(upstreamRes.headers["content-type"] || "").includes("event-stream");
|
|
472
475
|
const upstreamResponseFailed = (error) => {
|
|
473
|
-
|
|
476
|
+
// The release runs even when the queue has already settled. On an
|
|
477
|
+
// interrupt the client's socket close settles first, and this is the
|
|
478
|
+
// event that says the upstream stream is finally over.
|
|
479
|
+
if (settled) {
|
|
480
|
+
lease?.end();
|
|
474
481
|
return;
|
|
482
|
+
}
|
|
475
483
|
const message = `llama-server response ended unexpectedly: ${error.message}`;
|
|
476
484
|
telemetry.record({
|
|
477
485
|
at: new Date().toISOString(),
|
|
@@ -493,7 +501,7 @@ function proxyBuffered({ agent, model, supervisor, telemetry, logger, req, res,
|
|
|
493
501
|
let sawReasoning = false;
|
|
494
502
|
upstreamRes.on("data", (chunk) => {
|
|
495
503
|
const text = String(chunk);
|
|
496
|
-
|
|
504
|
+
lease?.observe(text);
|
|
497
505
|
if (chunkHasContent(text))
|
|
498
506
|
sawContent = true;
|
|
499
507
|
if (chunkHasReasoning(text))
|
|
@@ -571,13 +579,11 @@ function proxyBuffered({ agent, model, supervisor, telemetry, logger, req, res,
|
|
|
571
579
|
}
|
|
572
580
|
done();
|
|
573
581
|
});
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
// same moment, so `observe` stays free of any per-chunk slot work.
|
|
578
|
-
reasoning?.begin(streamId);
|
|
582
|
+
// The slot association is set once, here at dispatch, so `observe` stays
|
|
583
|
+
// free of any per-chunk slot work - and so the reaper has a row to check
|
|
584
|
+
// this request against if its release is ever missed.
|
|
579
585
|
if (slotId !== null)
|
|
580
|
-
|
|
586
|
+
lease?.setSlot(slotId);
|
|
581
587
|
upstream.end(outbound);
|
|
582
588
|
});
|
|
583
589
|
}
|
|
@@ -658,19 +664,24 @@ export function injectSystemAddendum(body, addendum, shape) {
|
|
|
658
664
|
* a switch; an unnamed request rides the pin.
|
|
659
665
|
*/
|
|
660
666
|
export function decideModelGate(params) {
|
|
661
|
-
const { lockModel, requestedName, pinned, resolved } = params;
|
|
667
|
+
const { lockModel, requestedName, pinned, pinnedModels = pinned ? [pinned] : [], resolved, } = params;
|
|
662
668
|
if (lockModel) {
|
|
663
|
-
if (
|
|
664
|
-
return { ok: false, status: 503, message: "no
|
|
669
|
+
if (pinnedModels.length === 0) {
|
|
670
|
+
return { ok: false, status: 503, message: "no models are selected on this locked host" };
|
|
665
671
|
}
|
|
666
|
-
|
|
672
|
+
const selected = requestedName
|
|
673
|
+
? pinnedModels.find((model) => model.id === requestedName || model.displayName === requestedName)
|
|
674
|
+
: pinnedModels[0];
|
|
675
|
+
if (!selected) {
|
|
667
676
|
return {
|
|
668
677
|
ok: false,
|
|
669
678
|
status: 409,
|
|
670
|
-
message: `model switching is disabled on this host;
|
|
679
|
+
message: `model switching is disabled on this host; served models: ${pinnedModels
|
|
680
|
+
.map((model) => `"${model.displayName}"`)
|
|
681
|
+
.join(", ")}`,
|
|
671
682
|
};
|
|
672
683
|
}
|
|
673
|
-
return { ok: true, model:
|
|
684
|
+
return { ok: true, model: selected };
|
|
674
685
|
}
|
|
675
686
|
if (!resolved) {
|
|
676
687
|
return {
|
|
@@ -684,7 +695,7 @@ export function decideModelGate(params) {
|
|
|
684
695
|
return { ok: true, model: resolved };
|
|
685
696
|
}
|
|
686
697
|
/** Buffer a completion request, resolve its target model, and queue it. */
|
|
687
|
-
function scheduleCompletion({ req, res, agent,
|
|
698
|
+
function scheduleCompletion({ req, res, agent, telemetry, logger, scheduler, modelGate, }) {
|
|
688
699
|
const chunks = [];
|
|
689
700
|
let size = 0;
|
|
690
701
|
let tooBig = false;
|
|
@@ -737,10 +748,22 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
|
|
|
737
748
|
// relaunched - and not at dispatch time either, where a sibling admitted in
|
|
738
749
|
// the same pass could see the same slot free and pin it too.
|
|
739
750
|
let slot = null;
|
|
740
|
-
|
|
751
|
+
// A queued request outlives its client. The reader can interrupt, close the
|
|
752
|
+
// chat, or lose the socket while the job still waits behind another model's
|
|
753
|
+
// turn, and without this the job is admitted anyway: a full generation for
|
|
754
|
+
// nobody, on a slot the live chats are queued for, wired to a response that
|
|
755
|
+
// closed before the proxy could listen to it. One flag, read by the
|
|
756
|
+
// scheduler before it pins anything, keeps the request from ever reaching
|
|
757
|
+
// the engine. (`close` also fires on a healthy finish, by which point the
|
|
758
|
+
// job has long since been dispatched and the flag is never read again.)
|
|
759
|
+
let abandoned = false;
|
|
760
|
+
res.on("close", () => {
|
|
761
|
+
abandoned = true;
|
|
762
|
+
});
|
|
763
|
+
const queued = scheduler.submit(model, (resident) => proxyBuffered({
|
|
741
764
|
agent,
|
|
742
765
|
model,
|
|
743
|
-
supervisor,
|
|
766
|
+
supervisor: resident,
|
|
744
767
|
telemetry,
|
|
745
768
|
logger,
|
|
746
769
|
req,
|
|
@@ -748,9 +771,15 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
|
|
|
748
771
|
body,
|
|
749
772
|
reasoning: reasoningTracker,
|
|
750
773
|
slot,
|
|
751
|
-
}), { session, onSlotFree: (id) => (slot = id) });
|
|
774
|
+
}), { session, onSlotFree: (id) => (slot = id), abandoned: () => abandoned });
|
|
752
775
|
logger?.info?.(`queued ${req.method ?? "POST"} ${req.url ?? "completion"} for ${model.displayName}; queue depth ${scheduler.stats().queued}`);
|
|
753
|
-
queued.catch((error) =>
|
|
776
|
+
queued.catch((error) => {
|
|
777
|
+
// The client may be the reason this failed, and writing into a socket it
|
|
778
|
+
// already closed throws where nothing is left to catch it.
|
|
779
|
+
if (res.writableEnded || res.destroyed)
|
|
780
|
+
return;
|
|
781
|
+
sendError(res, 502, `could not serve ${model.displayName}: ${errorMessage(error)}`);
|
|
782
|
+
});
|
|
754
783
|
});
|
|
755
784
|
}
|
|
756
785
|
// The bench ranking is read from disk (one JSON per run). A completion request
|
|
@@ -758,7 +787,7 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
|
|
|
758
787
|
// (rare), so the router caches the ranking and re-reads it at most once per
|
|
759
788
|
// window - the cheap time-based trigger.
|
|
760
789
|
const RANKING_TTL_MS = 60000;
|
|
761
|
-
export function createRouter({ supervisor, telemetry, logger, getCatalog = null, loadModel = null, loadRanking = () => rankModels(), queryGpuInfo = queryGpu, version = null, getConfig = null, getEvals = null, getLockModel = () => false, getDefaultModel = () => null, applyConfigPatch = null, getAllowConfigWrite = () => false, hostApi = null, getResources = null, statusEvents = null, scheduler: suppliedScheduler = null, }) {
|
|
790
|
+
export function createRouter({ supervisor, telemetry, logger, getCatalog = null, loadModel = null, loadRanking = () => rankModels(), queryGpuInfo = queryGpu, version = null, getConfig = null, getEvals = null, getLockModel = () => false, getDefaultModel = () => null, getLockedModels = () => [], applyConfigPatch = null, getAllowConfigWrite = () => false, hostApi = null, getResources = null, statusEvents = null, scheduler: suppliedScheduler = null, }) {
|
|
762
791
|
// llama-server may close an idle response socket while this scheduler holds
|
|
763
792
|
// the next request in queue. A reused keep-alive socket then fails as
|
|
764
793
|
// ECONNRESET ("socket hang up") before the queued request reaches inference.
|
|
@@ -792,6 +821,10 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
792
821
|
if (state === "starting") {
|
|
793
822
|
telemetry.reset();
|
|
794
823
|
scheduler?.forgetSlots();
|
|
824
|
+
// The tracker's pins name the same vanished slots, and a pin that
|
|
825
|
+
// outlives the process it named is not evidence - it can collide with a
|
|
826
|
+
// new request's slot id and shield a dead request from the reaper.
|
|
827
|
+
reasoningTracker.forgetSlots();
|
|
795
828
|
}
|
|
796
829
|
});
|
|
797
830
|
// GPU total VRAM is static hardware, so it is queried once at startup and
|
|
@@ -851,11 +884,20 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
851
884
|
// The single model this host will serve when switching is locked: the
|
|
852
885
|
// resident model if one is up, else the configured default resolved through
|
|
853
886
|
// the catalog. Null means nothing is loadable yet.
|
|
854
|
-
const
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
const
|
|
858
|
-
|
|
887
|
+
const pinnedModels = () => {
|
|
888
|
+
const configured = getLockedModels();
|
|
889
|
+
const names = configured.length > 0 ? configured : [getDefaultModel()].filter(Boolean);
|
|
890
|
+
const selected = names
|
|
891
|
+
.map((name) => resolveModel(name ?? null))
|
|
892
|
+
.filter((model) => model !== null);
|
|
893
|
+
if (selected.length > 0)
|
|
894
|
+
return selected;
|
|
895
|
+
const residents = scheduler && typeof scheduler.supervisors === "function"
|
|
896
|
+
? scheduler.supervisors()
|
|
897
|
+
: [supervisor];
|
|
898
|
+
return residents
|
|
899
|
+
.map((candidate) => candidate.model)
|
|
900
|
+
.filter((model) => model !== null);
|
|
859
901
|
};
|
|
860
902
|
// Decide whether a completion for `name` may run. The lock/default are read
|
|
861
903
|
// live so a POST /__host/config change takes effect without a restart. Only
|
|
@@ -865,7 +907,8 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
865
907
|
return decideModelGate({
|
|
866
908
|
lockModel: lock,
|
|
867
909
|
requestedName: name,
|
|
868
|
-
pinned:
|
|
910
|
+
pinned: null,
|
|
911
|
+
pinnedModels: lock ? pinnedModels() : [],
|
|
869
912
|
resolved: lock ? null : resolveModel(name),
|
|
870
913
|
});
|
|
871
914
|
};
|
|
@@ -880,6 +923,10 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
880
923
|
*/
|
|
881
924
|
const buildCheapStatus = async () => {
|
|
882
925
|
const schedulerStats = scheduler ? scheduler.stats() : null;
|
|
926
|
+
const residentSupervisors = scheduler && typeof scheduler.supervisors === "function"
|
|
927
|
+
? scheduler.supervisors()
|
|
928
|
+
: [supervisor];
|
|
929
|
+
const residents = residentSupervisors.filter((candidate) => candidate.model !== null && candidate.state !== "stopped");
|
|
883
930
|
// Slots come from a loopback GET on the resident llama-server. That is
|
|
884
931
|
// cheap enough to pay on every sample - unlike GPU sampling, which spawns
|
|
885
932
|
// `nvidia-smi` and stays opt-in. Skipped entirely unless a model is
|
|
@@ -887,6 +934,23 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
887
934
|
const slots = supervisor.state === "ready"
|
|
888
935
|
? await sampleSlots({ host: supervisor.host, port: supervisor.internalPort }).catch(() => null)
|
|
889
936
|
: null;
|
|
937
|
+
// Both truths are in hand exactly here, and nowhere else: `slots` is what
|
|
938
|
+
// llama-server says is running, `inference` is what the proxy believes. A
|
|
939
|
+
// missed release used to survive until the service restarted, because
|
|
940
|
+
// `reasoning` outranks every engine signal on the rail. Reconciling the two
|
|
941
|
+
// makes any such leak self-heal within a few samples, and `reconcile` is
|
|
942
|
+
// built so it can only ever clear a request the engine contradicts (see
|
|
943
|
+
// its own contract).
|
|
944
|
+
const reaped = reasoningTracker.reconcile({
|
|
945
|
+
busySlots: slots?.threads ? new Set(slots.threads.map((thread) => thread.slot)) : null,
|
|
946
|
+
busyCount: slots ? slots.busy : null,
|
|
947
|
+
});
|
|
948
|
+
for (const request of reaped) {
|
|
949
|
+
logger?.warn?.(`released inference stage ${request.id} (${request.stage}` +
|
|
950
|
+
`${request.slotId === null ? "" : `, slot ${request.slotId}`}) after ` +
|
|
951
|
+
`${Math.round(request.ageMs / 1000)}s the engine reported it idle - ` +
|
|
952
|
+
`a completion did not report its end`);
|
|
953
|
+
}
|
|
890
954
|
return {
|
|
891
955
|
version,
|
|
892
956
|
// Additive, and separate from `version`: the package version says which
|
|
@@ -895,6 +959,7 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
895
959
|
// package version.
|
|
896
960
|
apiVersion: HOST_API_VERSION,
|
|
897
961
|
...supervisor.status(),
|
|
962
|
+
residents: residents.map((resident) => resident.status()),
|
|
898
963
|
telemetry: { ...telemetry.totals, warning: telemetry.warning },
|
|
899
964
|
scheduler: schedulerStats,
|
|
900
965
|
recent: telemetry.records.slice(-10),
|
|
@@ -983,7 +1048,7 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
983
1048
|
return;
|
|
984
1049
|
// Answer model discovery ourselves so ids are real names (not paths), the
|
|
985
1050
|
// whole catalog is listed, and each carries LM Studio's context fields.
|
|
986
|
-
if (handleModelsRoute(req, res, supervisor, getCatalog))
|
|
1051
|
+
if (handleModelsRoute(req, res, supervisor, getCatalog, scheduler))
|
|
987
1052
|
return;
|
|
988
1053
|
// With a scheduler wired in, completion requests are queued and served in
|
|
989
1054
|
// turns - including loading/switching to the model they ask for - instead
|
|
@@ -993,7 +1058,6 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
993
1058
|
req,
|
|
994
1059
|
res,
|
|
995
1060
|
agent,
|
|
996
|
-
supervisor,
|
|
997
1061
|
telemetry,
|
|
998
1062
|
logger,
|
|
999
1063
|
scheduler,
|
|
@@ -1043,13 +1107,13 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
1043
1107
|
let sawContent = false;
|
|
1044
1108
|
let sawReasoning = false;
|
|
1045
1109
|
if (isCompletion) {
|
|
1046
|
-
const
|
|
1110
|
+
const lease = reasoningTracker.begin();
|
|
1047
1111
|
// Released on close, not just on end: an aborted stream would
|
|
1048
1112
|
// otherwise pin `/__host/status` on "thinking" forever.
|
|
1049
|
-
const releaseReasoning = () =>
|
|
1113
|
+
const releaseReasoning = () => lease.end();
|
|
1050
1114
|
upstreamRes.on("data", (chunk) => {
|
|
1051
1115
|
const text = String(chunk);
|
|
1052
|
-
|
|
1116
|
+
lease.observe(text);
|
|
1053
1117
|
if (chunkHasContent(text))
|
|
1054
1118
|
sawContent = true;
|
|
1055
1119
|
if (chunkHasReasoning(text))
|
|
@@ -84,6 +84,18 @@ export interface SchedulerSupervisor {
|
|
|
84
84
|
model: Model | null;
|
|
85
85
|
profile: Profile | null;
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* The scheduler surface consumed by the router and host operations. A managed
|
|
89
|
+
* process pool implements the same contract while choosing which resident
|
|
90
|
+
* supervisor owns each submitted model.
|
|
91
|
+
*/
|
|
92
|
+
export interface ModelScheduler<TSupervisor extends SchedulerSupervisor = SchedulerSupervisor> {
|
|
93
|
+
submit(model: Model, run: (supervisor: TSupervisor) => Promise<unknown>, options?: SchedulerSubmitOptions): Promise<unknown>;
|
|
94
|
+
stats(): SchedulerStats;
|
|
95
|
+
forgetSlots(): void;
|
|
96
|
+
supervisorFor(modelId: string): TSupervisor | null;
|
|
97
|
+
supervisors(): TSupervisor[];
|
|
98
|
+
}
|
|
87
99
|
/**
|
|
88
100
|
* The answer to a live slot measurement: how many sequence slots are free, and
|
|
89
101
|
* optionally WHICH ones. `idle` drives admission (see CAPACITY); `ids` lets
|
|
@@ -115,8 +127,8 @@ export interface SlotMeasurement {
|
|
|
115
127
|
* behavior but must never fail the completion that is about to run.
|
|
116
128
|
*/
|
|
117
129
|
export type SlotEraser = (slotId: number) => Promise<void>;
|
|
118
|
-
export interface SchedulerOptions {
|
|
119
|
-
supervisor:
|
|
130
|
+
export interface SchedulerOptions<TSupervisor extends SchedulerSupervisor = SchedulerSupervisor> {
|
|
131
|
+
supervisor: TSupervisor;
|
|
120
132
|
loadModel: (model: Model) => Promise<void>;
|
|
121
133
|
logger?: ((message: string) => void) | null;
|
|
122
134
|
/**
|
|
@@ -190,9 +202,23 @@ export interface SchedulerSubmitOptions {
|
|
|
190
202
|
* and their attribution is not per-slot.
|
|
191
203
|
*/
|
|
192
204
|
onSlotFree?: ((slotId: number | null) => void) | null;
|
|
205
|
+
/**
|
|
206
|
+
* Asked before this job is admitted: has whoever queued it stopped caring?
|
|
207
|
+
*
|
|
208
|
+
* A completion whose client left is the ordinary case - an interrupt, a
|
|
209
|
+
* closed chat, a dead socket - and it can happen at any point while the job
|
|
210
|
+
* waits behind another model's turn. Admitting it anyway costs a full
|
|
211
|
+
* generation of GPU time for a reader that is gone, holds one of the slots
|
|
212
|
+
* the remaining chats are queued for, and hands the proxy a response that
|
|
213
|
+
* closed before it could wire a listener to it. Dropping the job here, before
|
|
214
|
+
* it is pinned to a slot, is the cheapest and least surprising place to
|
|
215
|
+
* refuse: nothing reaches the engine, no slot is erased for a handoff that
|
|
216
|
+
* will not happen, and the job's promise settles like any other.
|
|
217
|
+
*/
|
|
218
|
+
abandoned?: (() => boolean) | null;
|
|
193
219
|
}
|
|
194
220
|
/** A queued request or host operation bound to a resolved catalog model. */
|
|
195
|
-
export interface QueuedJob {
|
|
221
|
+
export interface QueuedJob<TSupervisor extends SchedulerSupervisor = SchedulerSupervisor> {
|
|
196
222
|
modelId: string;
|
|
197
223
|
model: Model;
|
|
198
224
|
kind: SchedulerJobKind;
|
|
@@ -200,6 +226,7 @@ export interface QueuedJob {
|
|
|
200
226
|
session: string | null;
|
|
201
227
|
onStart: (() => void) | null;
|
|
202
228
|
onSlotFree: ((slotId: number | null) => void) | null;
|
|
229
|
+
abandoned: (() => boolean) | null;
|
|
203
230
|
/**
|
|
204
231
|
* The engine slot this job was pinned to at admission, or null when none was
|
|
205
232
|
* named. Kept on the job so a later pass can exclude it from the ids it hands
|
|
@@ -209,7 +236,7 @@ export interface QueuedJob {
|
|
|
209
236
|
* busy behavior would serialize the pair onto it while another slot sat empty.
|
|
210
237
|
*/
|
|
211
238
|
slotId: number | null;
|
|
212
|
-
run: () => Promise<unknown>;
|
|
239
|
+
run: (supervisor: TSupervisor) => Promise<unknown>;
|
|
213
240
|
resolve: (value: unknown) => void;
|
|
214
241
|
reject: (error: unknown) => void;
|
|
215
242
|
}
|
|
@@ -224,13 +251,13 @@ export interface SchedulerStats {
|
|
|
224
251
|
kind: Exclude<SchedulerJobKind, "completion">;
|
|
225
252
|
} | null;
|
|
226
253
|
}
|
|
227
|
-
export declare class Scheduler {
|
|
254
|
+
export declare class Scheduler<TSupervisor extends SchedulerSupervisor = SchedulerSupervisor> implements ModelScheduler<TSupervisor> {
|
|
228
255
|
#private;
|
|
229
|
-
supervisor:
|
|
256
|
+
supervisor: TSupervisor;
|
|
230
257
|
loadModel: (model: Model) => Promise<void>;
|
|
231
258
|
logger: ((message: string) => void) | null;
|
|
232
259
|
/** Submitted, not yet claimed by a turn. */
|
|
233
|
-
queue: QueuedJob[];
|
|
260
|
+
queue: QueuedJob<TSupervisor>[];
|
|
234
261
|
lastTurnId: string | null;
|
|
235
262
|
/**
|
|
236
263
|
* Per model: the session whose jobs most recently filled its slots. That
|
|
@@ -239,7 +266,7 @@ export declare class Scheduler {
|
|
|
239
266
|
*/
|
|
240
267
|
hotSessions: Map<string, string>;
|
|
241
268
|
/** The exclusive operation in flight, if any. Reported by `stats()`. */
|
|
242
|
-
activeJob: QueuedJob | null;
|
|
269
|
+
activeJob: QueuedJob<TSupervisor> | null;
|
|
243
270
|
onChange: (() => void) | null;
|
|
244
271
|
freeSlots: SchedulerOptions["freeSlots"];
|
|
245
272
|
slotPollMs: number;
|
|
@@ -252,7 +279,7 @@ export declare class Scheduler {
|
|
|
252
279
|
* engine reloads a model - slot ids do not survive the relaunch.
|
|
253
280
|
*/
|
|
254
281
|
slotOwners: Map<number, string | null>;
|
|
255
|
-
constructor({ supervisor, loadModel, logger, onChange, freeSlots, slotPollMs, eraseSlot, }: SchedulerOptions);
|
|
282
|
+
constructor({ supervisor, loadModel, logger, onChange, freeSlots, slotPollMs, eraseSlot, }: SchedulerOptions<TSupervisor>);
|
|
256
283
|
/** Id of the model that is actually loaded and ready, or null. */
|
|
257
284
|
get loadedId(): string | null;
|
|
258
285
|
/** How many requests may run at once against the resident model (static ceiling). */
|
|
@@ -263,7 +290,7 @@ export declare class Scheduler {
|
|
|
263
290
|
* Dispatch is deferred a microtask so a burst of requests submitted together
|
|
264
291
|
* shares one turn rather than the first one taking a turn by itself.
|
|
265
292
|
*/
|
|
266
|
-
submit(model: Model, run: () => Promise<unknown>, { kind, exclusive, onStart, session, onSlotFree, }?: SchedulerSubmitOptions): Promise<unknown>;
|
|
293
|
+
submit(model: Model, run: (supervisor: TSupervisor) => Promise<unknown>, { kind, exclusive, onStart, session, onSlotFree, abandoned, }?: SchedulerSubmitOptions): Promise<unknown>;
|
|
267
294
|
/**
|
|
268
295
|
* Drop every recorded slot owner. The engine's slots do not survive a model
|
|
269
296
|
* (re)launch, so their owners do not either - a stale entry would make the
|
|
@@ -279,6 +306,10 @@ export declare class Scheduler {
|
|
|
279
306
|
* supervisor says the slots are gone. Both paths are idempotent.
|
|
280
307
|
*/
|
|
281
308
|
forgetSlots(): void;
|
|
309
|
+
/** True only when this engine has no claimed, queued, or running work. */
|
|
310
|
+
get isIdle(): boolean;
|
|
311
|
+
supervisorFor(modelId: string): TSupervisor | null;
|
|
312
|
+
supervisors(): TSupervisor[];
|
|
282
313
|
/** Queue snapshot for the status endpoint / UI. */
|
|
283
314
|
stats(): SchedulerStats;
|
|
284
315
|
}
|
|
@@ -9,7 +9,7 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (
|
|
|
9
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
10
|
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
11
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;
|
|
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_purgeAbandoned, _Scheduler_warmSessions, _Scheduler_claimJob, _Scheduler_sampleFreeSlots, _Scheduler_pollForSlot, _Scheduler_start, _Scheduler_eraseFor, _Scheduler_dispatch, _Scheduler_resetSlots, _Scheduler_pass;
|
|
13
13
|
const MAX_CONCURRENCY = 16;
|
|
14
14
|
/** A short, safe description of an unknown error value for log lines. */
|
|
15
15
|
function describeError(error) {
|
|
@@ -82,7 +82,7 @@ export class Scheduler {
|
|
|
82
82
|
* Dispatch is deferred a microtask so a burst of requests submitted together
|
|
83
83
|
* shares one turn rather than the first one taking a turn by itself.
|
|
84
84
|
*/
|
|
85
|
-
submit(model, run, { kind = "completion", exclusive = kind !== "completion", onStart = null, session = null, onSlotFree = null, } = {}) {
|
|
85
|
+
submit(model, run, { kind = "completion", exclusive = kind !== "completion", onStart = null, session = null, onSlotFree = null, abandoned = null, } = {}) {
|
|
86
86
|
return new Promise((resolve, reject) => {
|
|
87
87
|
this.queue.push({
|
|
88
88
|
modelId: model.id,
|
|
@@ -92,6 +92,7 @@ export class Scheduler {
|
|
|
92
92
|
session: session ?? null,
|
|
93
93
|
onStart,
|
|
94
94
|
onSlotFree,
|
|
95
|
+
abandoned: abandoned ?? null,
|
|
95
96
|
slotId: null,
|
|
96
97
|
run,
|
|
97
98
|
resolve,
|
|
@@ -118,6 +119,19 @@ export class Scheduler {
|
|
|
118
119
|
forgetSlots() {
|
|
119
120
|
this.slotOwners.clear();
|
|
120
121
|
}
|
|
122
|
+
/** True only when this engine has no claimed, queued, or running work. */
|
|
123
|
+
get isIdle() {
|
|
124
|
+
return (this.queue.length === 0 &&
|
|
125
|
+
__classPrivateFieldGet(this, _Scheduler_batch, "f").length === 0 &&
|
|
126
|
+
__classPrivateFieldGet(this, _Scheduler_running, "f").size === 0 &&
|
|
127
|
+
this.activeJob === null);
|
|
128
|
+
}
|
|
129
|
+
supervisorFor(modelId) {
|
|
130
|
+
return this.supervisor.model?.id === modelId ? this.supervisor : null;
|
|
131
|
+
}
|
|
132
|
+
supervisors() {
|
|
133
|
+
return [this.supervisor];
|
|
134
|
+
}
|
|
121
135
|
/** Queue snapshot for the status endpoint / UI. */
|
|
122
136
|
stats() {
|
|
123
137
|
const waiting = {};
|
|
@@ -169,6 +183,33 @@ _Scheduler_batch = new WeakMap(), _Scheduler_running = new WeakMap(), _Scheduler
|
|
|
169
183
|
if (taken.length > 0)
|
|
170
184
|
__classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_announce).call(this);
|
|
171
185
|
return taken;
|
|
186
|
+
}, _Scheduler_purgeAbandoned = function _Scheduler_purgeAbandoned() {
|
|
187
|
+
const dropped = [];
|
|
188
|
+
const isAbandoned = (job) => {
|
|
189
|
+
try {
|
|
190
|
+
return job.abandoned?.() === true;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// A predicate that throws is not permission to drop the job.
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
__classPrivateFieldSet(this, _Scheduler_batch, __classPrivateFieldGet(this, _Scheduler_batch, "f").filter((job) => {
|
|
198
|
+
if (!isAbandoned(job))
|
|
199
|
+
return true;
|
|
200
|
+
dropped.push(job);
|
|
201
|
+
return false;
|
|
202
|
+
}), "f");
|
|
203
|
+
for (const job of __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_take).call(this, isAbandoned))
|
|
204
|
+
dropped.push(job);
|
|
205
|
+
if (dropped.length === 0)
|
|
206
|
+
return;
|
|
207
|
+
this.logger?.(`dropping ${dropped.length} queued job(s) whose caller went away`);
|
|
208
|
+
// Resolved, not rejected: the caller asked for this by leaving, and a
|
|
209
|
+
// rejection would only be reported into a socket that is already gone.
|
|
210
|
+
for (const job of dropped)
|
|
211
|
+
job.resolve(undefined);
|
|
212
|
+
__classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_announce).call(this);
|
|
172
213
|
}, _Scheduler_warmSessions = function _Scheduler_warmSessions() {
|
|
173
214
|
const warm = new Set();
|
|
174
215
|
for (const job of __classPrivateFieldGet(this, _Scheduler_running, "f"))
|
|
@@ -269,7 +310,7 @@ async function _Scheduler_start(job) {
|
|
|
269
310
|
void (async () => {
|
|
270
311
|
try {
|
|
271
312
|
job.onStart?.();
|
|
272
|
-
job.resolve(await job.run());
|
|
313
|
+
job.resolve(await job.run(this.supervisor));
|
|
273
314
|
}
|
|
274
315
|
catch (error) {
|
|
275
316
|
job.reject(error);
|
|
@@ -352,6 +393,11 @@ async function _Scheduler_pass() {
|
|
|
352
393
|
// the sample the first time capacity is checked; consumed in `#start`.
|
|
353
394
|
__classPrivateFieldSet(this, _Scheduler_freeSlotIds, null, "f");
|
|
354
395
|
for (;;) {
|
|
396
|
+
// Before any decision is derived from the queue, drop what nobody is
|
|
397
|
+
// waiting for, so every count read below is honest. Per iteration rather
|
|
398
|
+
// than once per pass: a turn boundary is crossed inside this loop, and a
|
|
399
|
+
// caller can leave while the job ahead of it is being started.
|
|
400
|
+
__classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_purgeAbandoned).call(this);
|
|
355
401
|
// An exclusive operation owns the engine alone.
|
|
356
402
|
if (this.activeJob !== null)
|
|
357
403
|
return;
|