@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.
@@ -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 loadedId = supervisor.model ? supervisor.model.id : null;
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
- if (!loadedId || model.id !== loadedId)
214
+ const resident = residentFor(model.id);
215
+ if (!resident)
212
216
  return "not-loaded";
213
- if (supervisor.state === "ready")
217
+ if (resident.state === "ready")
214
218
  return "loaded";
215
- if (supervisor.state === "starting")
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
- if (supervisor.model && !catalog.some((m) => m.id === loadedId)) {
222
- catalog = [supervisor.model, ...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) => describeModel(model, {
225
- state: stateOf(model),
226
- profile: model.id === loadedId ? supervisor.profile : null,
227
- createdAt: model.id === loadedId ? supervisor.startedAt : null,
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
- const streamId = nextStreamId();
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 reasoning flag, including on the error and abort
435
- // paths: a stream that dies mid-thought would otherwise pin the rail on
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
- reasoning?.end(streamId);
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
- if (settled)
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
- reasoning?.observe(streamId, text);
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
- // This is the first authoritative inference-stage signal: the request is
575
- // being dispatched to llama-server and is waiting for prompt processing or
576
- // its first output delta. The slot association is set once here, at the
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
- reasoning?.setSlot(streamId, slotId);
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 (!pinned) {
664
- return { ok: false, status: 503, message: "no model is loaded yet on this locked host" };
669
+ if (pinnedModels.length === 0) {
670
+ return { ok: false, status: 503, message: "no models are selected on this locked host" };
665
671
  }
666
- if (requestedName && requestedName !== pinned.id && requestedName !== pinned.displayName) {
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; only "${pinned.displayName}" is served`,
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: pinned };
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, supervisor, telemetry, logger, scheduler, modelGate, }) {
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
- const queued = scheduler.submit(model, () => proxyBuffered({
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) => sendError(res, 502, `could not serve ${model.displayName}: ${errorMessage(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 pinnedModel = () => {
855
- if (supervisor.model)
856
- return supervisor.model;
857
- const def = getDefaultModel();
858
- return def ? resolveModel(def) : null;
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: lock ? pinnedModel() : null,
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 streamId = nextStreamId();
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 = () => reasoningTracker.end(streamId);
1113
+ const releaseReasoning = () => lease.end();
1050
1114
  upstreamRes.on("data", (chunk) => {
1051
1115
  const text = String(chunk);
1052
- reasoningTracker.observe(streamId, text);
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: SchedulerSupervisor;
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: SchedulerSupervisor;
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;