@otto-code/brain 0.8.13 → 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/pull.d.ts +1 -1
- package/dist/service/activity.d.ts +95 -14
- package/dist/service/activity.js +206 -78
- package/dist/service/router.js +63 -27
- package/dist/service/scheduler.d.ts +16 -1
- package/dist/service/scheduler.js +35 -2
- package/package.json +1 -1
package/dist/commands/pull.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `otto brain pull <model>` - download a model from the catalog into the managed
|
|
3
3
|
* models directory, using only Node's fetch (no external downloader). The catalog
|
|
4
|
-
* is the same one seeded from docs/
|
|
4
|
+
* is the same one seeded from docs/brain-model-catalog.md. `--list-quants` shows what
|
|
5
5
|
* quantizations the repo offers and `--quant <label>` downloads a specific one.
|
|
6
6
|
*/
|
|
7
7
|
import type { Command } from "commander";
|
|
@@ -73,6 +73,41 @@ export interface InferenceActivitySnapshot {
|
|
|
73
73
|
*/
|
|
74
74
|
slotStages?: Record<string, InferenceStage>;
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* A caller's exclusive claim on one tracked request.
|
|
78
|
+
*
|
|
79
|
+
* Handed out by `ReasoningTracker.begin` and released exactly once. Releasing
|
|
80
|
+
* is terminal: every method on a released lease is a no-op, so a chunk that
|
|
81
|
+
* arrives after the client has gone cannot revive the request it names. That is
|
|
82
|
+
* not a theoretical concern - it is the shape of the bug this type exists to
|
|
83
|
+
* make impossible (see `begin`).
|
|
84
|
+
*/
|
|
85
|
+
export interface InferenceLease {
|
|
86
|
+
/** This request's id, for logs and for joining to an engine slot. */
|
|
87
|
+
readonly id: string;
|
|
88
|
+
observe(text: string): void;
|
|
89
|
+
setSlot(slotId: number): void;
|
|
90
|
+
/** Idempotent, and safe to call from every branch that can end a stream. */
|
|
91
|
+
end(): void;
|
|
92
|
+
}
|
|
93
|
+
/** What the engine says about itself, for `ReasoningTracker.reconcile`. */
|
|
94
|
+
export interface EngineSlotTruth {
|
|
95
|
+
/**
|
|
96
|
+
* Slot ids llama-server reports as actively processing, or null when this
|
|
97
|
+
* build reports a count but no per-slot rows. Null demotes every request to
|
|
98
|
+
* the conservative unpinned rule.
|
|
99
|
+
*/
|
|
100
|
+
busySlots: ReadonlySet<number> | null;
|
|
101
|
+
/** How many slots are busy, or null when the sample failed - which reaps nothing. */
|
|
102
|
+
busyCount: number | null;
|
|
103
|
+
}
|
|
104
|
+
/** One request the reaper cleared, and the evidence worth logging about it. */
|
|
105
|
+
export interface ReapedRequest {
|
|
106
|
+
id: string;
|
|
107
|
+
stage: InferenceStage;
|
|
108
|
+
slotId: number | null;
|
|
109
|
+
ageMs: number;
|
|
110
|
+
}
|
|
76
111
|
/**
|
|
77
112
|
* Which in-flight completions are currently mid-thought.
|
|
78
113
|
*
|
|
@@ -97,23 +132,69 @@ export declare class ReasoningTracker {
|
|
|
97
132
|
* one stage do not notify; slot sampling owns bounded token-rate updates.
|
|
98
133
|
*/
|
|
99
134
|
onChange(listener: () => void): () => void;
|
|
100
|
-
/** A completion was dispatched to llama-server and awaits its first output delta. */
|
|
101
|
-
begin(requestId: string): void;
|
|
102
135
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
136
|
+
* Open a lease for a completion that has just been dispatched to
|
|
137
|
+
* llama-server and awaits its first output delta.
|
|
138
|
+
*
|
|
139
|
+
* A lease rather than an id the caller carries around, because every stuck
|
|
140
|
+
* "thinking" this tracker has produced was a release that did not happen on
|
|
141
|
+
* some branch of the proxy's event wiring. A lease makes both halves of that
|
|
142
|
+
* bug unrepresentable: nothing can advance a request without holding its
|
|
143
|
+
* lease, and a released lease is inert, so a chunk that lands after the
|
|
144
|
+
* release cannot resurrect the request it belongs to. (The same shape as
|
|
145
|
+
* `beginActivity` above, for the same reason.)
|
|
146
|
+
*
|
|
147
|
+
* Ids are minted here rather than by the caller: two callers sharing one id
|
|
148
|
+
* would silently share one request's state.
|
|
149
|
+
*/
|
|
150
|
+
begin(): InferenceLease;
|
|
151
|
+
/**
|
|
152
|
+
* Forget every slot pin, because the engine's slots did not survive its
|
|
153
|
+
* relaunch.
|
|
154
|
+
*
|
|
155
|
+
* The mirror of `Scheduler.forgetSlots`, and required for the same reason: a
|
|
156
|
+
* pin that outlives the process it named is no longer evidence. Worse than
|
|
157
|
+
* useless, in fact - a stale pin can collide with a NEW request's slot id,
|
|
158
|
+
* and the reaper would read that unrelated busy row as proof the dead request
|
|
159
|
+
* is still alive. Dropping the pins demotes those requests to the
|
|
160
|
+
* conservative unpinned rule, which clears them once the engine is quiet.
|
|
161
|
+
*/
|
|
162
|
+
forgetSlots(): void;
|
|
163
|
+
/**
|
|
164
|
+
* Drop tracked requests the engine's own account of itself contradicts.
|
|
165
|
+
*
|
|
166
|
+
* The safety net under the lease, and it exists because `active` outranks
|
|
167
|
+
* every engine signal on the rail: one release that never happened claims
|
|
168
|
+
* "thinking" until the service restarts. The ops tracker already refuses that
|
|
169
|
+
* bargain by probing the recorded pid, on the principle that a status stuck
|
|
170
|
+
* on "calibrating" forever is worse than no status at all. This is the
|
|
171
|
+
* inference half of the same rule.
|
|
172
|
+
*
|
|
173
|
+
* **It must never clear valid work**, so it acts only on positive evidence,
|
|
174
|
+
* and only on evidence a live request could not produce:
|
|
175
|
+
*
|
|
176
|
+
* 1. A request that has sent a chunk (or been pinned, or been dispatched)
|
|
177
|
+
* within `INFERENCE_QUIET_MS` is alive. A streaming request is therefore
|
|
178
|
+
* never a candidate at all, whatever the engine says this instant.
|
|
179
|
+
* 2. A PINNED request is checked against its own slot. llama-server marks a
|
|
180
|
+
* slot processing for the whole task, prefill included, so a request that
|
|
181
|
+
* is genuinely running makes its row busy. That row being idle is the
|
|
182
|
+
* contradiction. This is what lets one chat's leak be cleared while
|
|
183
|
+
* another chat keeps generating.
|
|
184
|
+
* 3. An UNPINNED request - or any request when the engine reports no
|
|
185
|
+
* per-slot rows - cannot be attributed to a row, so it is cleared only
|
|
186
|
+
* when the engine reports nothing running at all. Ambiguity is not
|
|
187
|
+
* evidence.
|
|
188
|
+
* 4. The contradiction has to hold for `INFERENCE_STRIKES` samples in a row.
|
|
189
|
+
* A single sample can race the dispatch window, where a request has been
|
|
190
|
+
* begun and the engine has not picked it up yet; a run of them cannot.
|
|
191
|
+
*
|
|
192
|
+
* A failed slot sample is not evidence either, and reconciles nothing.
|
|
107
193
|
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* restarted engine hands a task out again) reports where it is now.
|
|
194
|
+
* Returns what it reaped, so the caller can log a leak rather than silently
|
|
195
|
+
* paper over it.
|
|
111
196
|
*/
|
|
112
|
-
|
|
113
|
-
/** Note a chunk of `requestId`'s stream. Cheap enough to call per chunk. */
|
|
114
|
-
observe(requestId: string, text: string): void;
|
|
115
|
-
/** Forget the request. Must be called on end *and* on error, or the flag sticks. */
|
|
116
|
-
end(requestId: string): void;
|
|
197
|
+
reconcile(truth: EngineSlotTruth): ReapedRequest[];
|
|
117
198
|
get active(): boolean;
|
|
118
199
|
get count(): number;
|
|
119
200
|
/** Aggregate request stages. Counts stay exact even with several parallel slots. */
|
package/dist/service/activity.js
CHANGED
|
@@ -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 _ReasoningTracker_instances, _ReasoningTracker_requests,
|
|
12
|
+
var _ReasoningTracker_instances, _ReasoningTracker_requests, _ReasoningTracker_listeners, _ReasoningTracker_lastSnapshot, _ReasoningTracker_counter, _ReasoningTracker_announce, _ReasoningTracker_setSlot, _ReasoningTracker_observe, _ReasoningTracker_touch, _ReasoningTracker_drop;
|
|
13
13
|
/**
|
|
14
14
|
* What long-running work currently owns the brain, and which stage each live
|
|
15
15
|
* inference request has reached.
|
|
@@ -179,6 +179,23 @@ export function chunkHasContent(text) {
|
|
|
179
179
|
/"tool_calls"\s*:\s*\[\s*\{/u.test(text) ||
|
|
180
180
|
/"type"\s*:\s*"(?:tool_use|input_json_delta)"/u.test(text));
|
|
181
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* How long a request may be silent before the reaper will consider it at all.
|
|
184
|
+
*
|
|
185
|
+
* Long enough to cover the gap between proxy dispatch and llama-server picking
|
|
186
|
+
* the task up, which is the one window where a healthy request and an idle
|
|
187
|
+
* engine legitimately coexist.
|
|
188
|
+
*/
|
|
189
|
+
const INFERENCE_QUIET_MS = 5000;
|
|
190
|
+
/**
|
|
191
|
+
* How many consecutive contradicting samples clear a request.
|
|
192
|
+
*
|
|
193
|
+
* More than one because a single sample can catch a real dispatch mid-flight;
|
|
194
|
+
* small because the status sampler runs about once a second while anything
|
|
195
|
+
* claims to be busy, so a genuine leak is gone in seconds rather than surviving
|
|
196
|
+
* until someone restarts the service.
|
|
197
|
+
*/
|
|
198
|
+
const INFERENCE_STRIKES = 3;
|
|
182
199
|
/**
|
|
183
200
|
* Which in-flight completions are currently mid-thought.
|
|
184
201
|
*
|
|
@@ -197,18 +214,9 @@ export class ReasoningTracker {
|
|
|
197
214
|
constructor() {
|
|
198
215
|
_ReasoningTracker_instances.add(this);
|
|
199
216
|
_ReasoningTracker_requests.set(this, new Map());
|
|
200
|
-
/**
|
|
201
|
-
* The llama-server slot a request was pinned to at dispatch, so its proxy-side
|
|
202
|
-
* stage can be attributed to the engine row the panel actually shows. Set once
|
|
203
|
-
* per request (see `setSlot`), never on the per-chunk path.
|
|
204
|
-
*/
|
|
205
|
-
_ReasoningTracker_slots.set(this, new Map());
|
|
206
|
-
/** Tail of the last transport chunk, so a field name split by TCP is still detected. */
|
|
207
|
-
_ReasoningTracker_tails.set(this, new Map());
|
|
208
|
-
/** Models/runtimes that leave reasoning inline as `<think>…</think>`. */
|
|
209
|
-
_ReasoningTracker_inlineReasoning.set(this, new Set());
|
|
210
217
|
_ReasoningTracker_listeners.set(this, new Set());
|
|
211
|
-
_ReasoningTracker_lastSnapshot.set(this, "
|
|
218
|
+
_ReasoningTracker_lastSnapshot.set(this, "");
|
|
219
|
+
_ReasoningTracker_counter.set(this, 0);
|
|
212
220
|
}
|
|
213
221
|
/**
|
|
214
222
|
* Watch stage counts, not the per-chunk traffic behind them.
|
|
@@ -221,74 +229,142 @@ export class ReasoningTracker {
|
|
|
221
229
|
__classPrivateFieldGet(this, _ReasoningTracker_listeners, "f").add(listener);
|
|
222
230
|
return () => __classPrivateFieldGet(this, _ReasoningTracker_listeners, "f").delete(listener);
|
|
223
231
|
}
|
|
224
|
-
/** A completion was dispatched to llama-server and awaits its first output delta. */
|
|
225
|
-
begin(requestId) {
|
|
226
|
-
if (__classPrivateFieldGet(this, _ReasoningTracker_requests, "f").has(requestId))
|
|
227
|
-
return;
|
|
228
|
-
__classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "processing");
|
|
229
|
-
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
|
|
230
|
-
}
|
|
231
232
|
/**
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
233
|
+
* Open a lease for a completion that has just been dispatched to
|
|
234
|
+
* llama-server and awaits its first output delta.
|
|
235
|
+
*
|
|
236
|
+
* A lease rather than an id the caller carries around, because every stuck
|
|
237
|
+
* "thinking" this tracker has produced was a release that did not happen on
|
|
238
|
+
* some branch of the proxy's event wiring. A lease makes both halves of that
|
|
239
|
+
* bug unrepresentable: nothing can advance a request without holding its
|
|
240
|
+
* lease, and a released lease is inert, so a chunk that lands after the
|
|
241
|
+
* release cannot resurrect the request it belongs to. (The same shape as
|
|
242
|
+
* `beginActivity` above, for the same reason.)
|
|
236
243
|
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
* restarted engine hands a task out again) reports where it is now.
|
|
244
|
+
* Ids are minted here rather than by the caller: two callers sharing one id
|
|
245
|
+
* would silently share one request's state.
|
|
240
246
|
*/
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
+
begin() {
|
|
248
|
+
__classPrivateFieldSet(this, _ReasoningTracker_counter, __classPrivateFieldGet(this, _ReasoningTracker_counter, "f") + 1, "f");
|
|
249
|
+
const id = `s${__classPrivateFieldGet(this, _ReasoningTracker_counter, "f")}`;
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
__classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(id, {
|
|
252
|
+
stage: "processing",
|
|
253
|
+
slotId: null,
|
|
254
|
+
tail: "",
|
|
255
|
+
inlineReasoning: false,
|
|
256
|
+
startedAt: now,
|
|
257
|
+
lastSignalAt: now,
|
|
258
|
+
strikes: 0,
|
|
259
|
+
});
|
|
247
260
|
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
|
|
261
|
+
let open = true;
|
|
262
|
+
return {
|
|
263
|
+
id,
|
|
264
|
+
observe: (text) => {
|
|
265
|
+
if (!open)
|
|
266
|
+
return;
|
|
267
|
+
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_observe).call(this, id, text);
|
|
268
|
+
},
|
|
269
|
+
setSlot: (slotId) => {
|
|
270
|
+
if (!open)
|
|
271
|
+
return;
|
|
272
|
+
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_setSlot).call(this, id, slotId);
|
|
273
|
+
},
|
|
274
|
+
end: () => {
|
|
275
|
+
if (!open)
|
|
276
|
+
return;
|
|
277
|
+
open = false;
|
|
278
|
+
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_drop).call(this, [id]);
|
|
279
|
+
},
|
|
280
|
+
};
|
|
248
281
|
}
|
|
249
|
-
/**
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
if (combined.includes("<think>")) {
|
|
270
|
-
__classPrivateFieldGet(this, _ReasoningTracker_inlineReasoning, "f").add(requestId);
|
|
271
|
-
__classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "thinking");
|
|
272
|
-
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
if (chunkHasContent(combined)) {
|
|
276
|
-
__classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "generating");
|
|
277
|
-
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
|
|
278
|
-
return;
|
|
282
|
+
/**
|
|
283
|
+
* Forget every slot pin, because the engine's slots did not survive its
|
|
284
|
+
* relaunch.
|
|
285
|
+
*
|
|
286
|
+
* The mirror of `Scheduler.forgetSlots`, and required for the same reason: a
|
|
287
|
+
* pin that outlives the process it named is no longer evidence. Worse than
|
|
288
|
+
* useless, in fact - a stale pin can collide with a NEW request's slot id,
|
|
289
|
+
* and the reaper would read that unrelated busy row as proof the dead request
|
|
290
|
+
* is still alive. Dropping the pins demotes those requests to the
|
|
291
|
+
* conservative unpinned rule, which clears them once the engine is quiet.
|
|
292
|
+
*/
|
|
293
|
+
forgetSlots() {
|
|
294
|
+
let changed = false;
|
|
295
|
+
for (const state of __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").values()) {
|
|
296
|
+
if (state.slotId === null)
|
|
297
|
+
continue;
|
|
298
|
+
state.slotId = null;
|
|
299
|
+
changed = true;
|
|
279
300
|
}
|
|
280
|
-
if (
|
|
281
|
-
__classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "thinking");
|
|
301
|
+
if (changed)
|
|
282
302
|
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
|
|
283
|
-
}
|
|
284
303
|
}
|
|
285
|
-
/**
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
304
|
+
/**
|
|
305
|
+
* Drop tracked requests the engine's own account of itself contradicts.
|
|
306
|
+
*
|
|
307
|
+
* The safety net under the lease, and it exists because `active` outranks
|
|
308
|
+
* every engine signal on the rail: one release that never happened claims
|
|
309
|
+
* "thinking" until the service restarts. The ops tracker already refuses that
|
|
310
|
+
* bargain by probing the recorded pid, on the principle that a status stuck
|
|
311
|
+
* on "calibrating" forever is worse than no status at all. This is the
|
|
312
|
+
* inference half of the same rule.
|
|
313
|
+
*
|
|
314
|
+
* **It must never clear valid work**, so it acts only on positive evidence,
|
|
315
|
+
* and only on evidence a live request could not produce:
|
|
316
|
+
*
|
|
317
|
+
* 1. A request that has sent a chunk (or been pinned, or been dispatched)
|
|
318
|
+
* within `INFERENCE_QUIET_MS` is alive. A streaming request is therefore
|
|
319
|
+
* never a candidate at all, whatever the engine says this instant.
|
|
320
|
+
* 2. A PINNED request is checked against its own slot. llama-server marks a
|
|
321
|
+
* slot processing for the whole task, prefill included, so a request that
|
|
322
|
+
* is genuinely running makes its row busy. That row being idle is the
|
|
323
|
+
* contradiction. This is what lets one chat's leak be cleared while
|
|
324
|
+
* another chat keeps generating.
|
|
325
|
+
* 3. An UNPINNED request - or any request when the engine reports no
|
|
326
|
+
* per-slot rows - cannot be attributed to a row, so it is cleared only
|
|
327
|
+
* when the engine reports nothing running at all. Ambiguity is not
|
|
328
|
+
* evidence.
|
|
329
|
+
* 4. The contradiction has to hold for `INFERENCE_STRIKES` samples in a row.
|
|
330
|
+
* A single sample can race the dispatch window, where a request has been
|
|
331
|
+
* begun and the engine has not picked it up yet; a run of them cannot.
|
|
332
|
+
*
|
|
333
|
+
* A failed slot sample is not evidence either, and reconciles nothing.
|
|
334
|
+
*
|
|
335
|
+
* Returns what it reaped, so the caller can log a leak rather than silently
|
|
336
|
+
* paper over it.
|
|
337
|
+
*/
|
|
338
|
+
reconcile(truth) {
|
|
339
|
+
if (truth.busyCount === null)
|
|
340
|
+
return [];
|
|
341
|
+
const now = Date.now();
|
|
342
|
+
const quietBefore = now - INFERENCE_QUIET_MS;
|
|
343
|
+
const reaped = [];
|
|
344
|
+
for (const [id, state] of __classPrivateFieldGet(this, _ReasoningTracker_requests, "f")) {
|
|
345
|
+
if (state.lastSignalAt > quietBefore) {
|
|
346
|
+
state.strikes = 0;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const contradicted = state.slotId !== null && truth.busySlots
|
|
350
|
+
? !truth.busySlots.has(state.slotId)
|
|
351
|
+
: truth.busyCount === 0;
|
|
352
|
+
if (!contradicted) {
|
|
353
|
+
state.strikes = 0;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
state.strikes += 1;
|
|
357
|
+
if (state.strikes < INFERENCE_STRIKES)
|
|
358
|
+
continue;
|
|
359
|
+
reaped.push({
|
|
360
|
+
id,
|
|
361
|
+
stage: state.stage,
|
|
362
|
+
slotId: state.slotId,
|
|
363
|
+
ageMs: now - state.startedAt,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_drop).call(this, reaped.map((entry) => entry.id));
|
|
367
|
+
return reaped;
|
|
292
368
|
}
|
|
293
369
|
get active() {
|
|
294
370
|
return this.snapshot.thinking > 0;
|
|
@@ -305,19 +381,18 @@ export class ReasoningTracker {
|
|
|
305
381
|
generating: 0,
|
|
306
382
|
};
|
|
307
383
|
let slotStages;
|
|
308
|
-
for (const
|
|
309
|
-
result[stage] += 1;
|
|
310
|
-
|
|
311
|
-
if (slot === undefined)
|
|
384
|
+
for (const state of __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").values()) {
|
|
385
|
+
result[state.stage] += 1;
|
|
386
|
+
if (state.slotId === null)
|
|
312
387
|
continue;
|
|
313
|
-
(slotStages ?? (slotStages = {}))[String(
|
|
388
|
+
(slotStages ?? (slotStages = {}))[String(state.slotId)] = state.stage;
|
|
314
389
|
}
|
|
315
390
|
if (slotStages)
|
|
316
391
|
result.slotStages = slotStages;
|
|
317
392
|
return result;
|
|
318
393
|
}
|
|
319
394
|
}
|
|
320
|
-
_ReasoningTracker_requests = new WeakMap(),
|
|
395
|
+
_ReasoningTracker_requests = new WeakMap(), _ReasoningTracker_listeners = new WeakMap(), _ReasoningTracker_lastSnapshot = new WeakMap(), _ReasoningTracker_counter = new WeakMap(), _ReasoningTracker_instances = new WeakSet(), _ReasoningTracker_announce = function _ReasoningTracker_announce() {
|
|
321
396
|
const snapshot = this.snapshot;
|
|
322
397
|
// The slot join rides in the key too: pinning a request to a slot is a
|
|
323
398
|
// state change even when no stage count moves, and it is the field the
|
|
@@ -341,5 +416,58 @@ _ReasoningTracker_requests = new WeakMap(), _ReasoningTracker_slots = new WeakMa
|
|
|
341
416
|
// Status reporting must never break a proxied completion.
|
|
342
417
|
}
|
|
343
418
|
}
|
|
419
|
+
}, _ReasoningTracker_setSlot = function _ReasoningTracker_setSlot(id, slotId) {
|
|
420
|
+
if (!Number.isInteger(slotId) || slotId < 0)
|
|
421
|
+
return;
|
|
422
|
+
const state = __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").get(id);
|
|
423
|
+
if (!state || state.slotId === slotId)
|
|
424
|
+
return;
|
|
425
|
+
state.slotId = slotId;
|
|
426
|
+
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_touch).call(this, state);
|
|
427
|
+
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
|
|
428
|
+
}, _ReasoningTracker_observe = function _ReasoningTracker_observe(id, text) {
|
|
429
|
+
const state = __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").get(id);
|
|
430
|
+
if (!state)
|
|
431
|
+
return;
|
|
432
|
+
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_touch).call(this, state);
|
|
433
|
+
if (state.stage === "generating")
|
|
434
|
+
return;
|
|
435
|
+
// Node can split an SSE JSON field name at any byte. Keeping a small tail
|
|
436
|
+
// makes stage recognition independent of transport chunk boundaries without
|
|
437
|
+
// parsing or retaining the generated content itself.
|
|
438
|
+
const combined = `${state.tail}${text}`;
|
|
439
|
+
state.tail = combined.slice(-128);
|
|
440
|
+
if (state.inlineReasoning) {
|
|
441
|
+
if (combined.includes("</think>")) {
|
|
442
|
+
state.inlineReasoning = false;
|
|
443
|
+
state.stage = "generating";
|
|
444
|
+
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
|
|
445
|
+
}
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (combined.includes("<think>")) {
|
|
449
|
+
state.inlineReasoning = true;
|
|
450
|
+
state.stage = "thinking";
|
|
451
|
+
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (chunkHasContent(combined)) {
|
|
455
|
+
state.stage = "generating";
|
|
456
|
+
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (chunkHasReasoning(combined) && state.stage !== "thinking") {
|
|
460
|
+
state.stage = "thinking";
|
|
461
|
+
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
|
|
462
|
+
}
|
|
463
|
+
}, _ReasoningTracker_touch = function _ReasoningTracker_touch(state) {
|
|
464
|
+
state.lastSignalAt = Date.now();
|
|
465
|
+
state.strikes = 0;
|
|
466
|
+
}, _ReasoningTracker_drop = function _ReasoningTracker_drop(ids) {
|
|
467
|
+
let removed = false;
|
|
468
|
+
for (const id of ids)
|
|
469
|
+
removed = __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").delete(id) || removed;
|
|
470
|
+
if (removed)
|
|
471
|
+
__classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
|
|
344
472
|
};
|
|
345
473
|
//# sourceMappingURL=activity.js.map
|
package/dist/service/router.js
CHANGED
|
@@ -273,16 +273,6 @@ function handleModelsRoute(req, res, supervisor, getCatalog, scheduler = null) {
|
|
|
273
273
|
res.end(body);
|
|
274
274
|
return true;
|
|
275
275
|
}
|
|
276
|
-
/**
|
|
277
|
-
* Correlates the chunks of one proxied stream for the reasoning tracker. A
|
|
278
|
-
* counter rather than a uuid: it never leaves the process and only has to be
|
|
279
|
-
* unique among the handful of streams in flight at once.
|
|
280
|
-
*/
|
|
281
|
-
let streamCounter = 0;
|
|
282
|
-
function nextStreamId() {
|
|
283
|
-
streamCounter += 1;
|
|
284
|
-
return `s${streamCounter}`;
|
|
285
|
-
}
|
|
286
276
|
/**
|
|
287
277
|
* The reasoning tracker is module-scoped rather than per-router because both
|
|
288
278
|
* proxy paths need it and `proxyBuffered` is a free function. One service
|
|
@@ -438,12 +428,16 @@ function proxyBuffered({ agent, model, supervisor, telemetry, logger, req, res,
|
|
|
438
428
|
const slotId = slot ?? null;
|
|
439
429
|
return new Promise((resolve) => {
|
|
440
430
|
let settled = false;
|
|
441
|
-
|
|
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;
|
|
442
436
|
const done = () => {
|
|
443
|
-
// Always release the
|
|
444
|
-
//
|
|
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
|
|
445
439
|
// "thinking" until the service restarts.
|
|
446
|
-
|
|
440
|
+
lease?.end();
|
|
447
441
|
if (!settled) {
|
|
448
442
|
settled = true;
|
|
449
443
|
resolve();
|
|
@@ -479,8 +473,13 @@ function proxyBuffered({ agent, model, supervisor, telemetry, logger, req, res,
|
|
|
479
473
|
res.writeHead(upstreamRes.statusCode ?? 502, outHeaders);
|
|
480
474
|
const isStream = String(upstreamRes.headers["content-type"] || "").includes("event-stream");
|
|
481
475
|
const upstreamResponseFailed = (error) => {
|
|
482
|
-
|
|
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();
|
|
483
481
|
return;
|
|
482
|
+
}
|
|
484
483
|
const message = `llama-server response ended unexpectedly: ${error.message}`;
|
|
485
484
|
telemetry.record({
|
|
486
485
|
at: new Date().toISOString(),
|
|
@@ -502,7 +501,7 @@ function proxyBuffered({ agent, model, supervisor, telemetry, logger, req, res,
|
|
|
502
501
|
let sawReasoning = false;
|
|
503
502
|
upstreamRes.on("data", (chunk) => {
|
|
504
503
|
const text = String(chunk);
|
|
505
|
-
|
|
504
|
+
lease?.observe(text);
|
|
506
505
|
if (chunkHasContent(text))
|
|
507
506
|
sawContent = true;
|
|
508
507
|
if (chunkHasReasoning(text))
|
|
@@ -580,13 +579,11 @@ function proxyBuffered({ agent, model, supervisor, telemetry, logger, req, res,
|
|
|
580
579
|
}
|
|
581
580
|
done();
|
|
582
581
|
});
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
// same moment, so `observe` stays free of any per-chunk slot work.
|
|
587
|
-
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.
|
|
588
585
|
if (slotId !== null)
|
|
589
|
-
|
|
586
|
+
lease?.setSlot(slotId);
|
|
590
587
|
upstream.end(outbound);
|
|
591
588
|
});
|
|
592
589
|
}
|
|
@@ -751,6 +748,18 @@ function scheduleCompletion({ req, res, agent, telemetry, logger, scheduler, mod
|
|
|
751
748
|
// relaunched - and not at dispatch time either, where a sibling admitted in
|
|
752
749
|
// the same pass could see the same slot free and pin it too.
|
|
753
750
|
let slot = null;
|
|
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
|
+
});
|
|
754
763
|
const queued = scheduler.submit(model, (resident) => proxyBuffered({
|
|
755
764
|
agent,
|
|
756
765
|
model,
|
|
@@ -762,9 +771,15 @@ function scheduleCompletion({ req, res, agent, telemetry, logger, scheduler, mod
|
|
|
762
771
|
body,
|
|
763
772
|
reasoning: reasoningTracker,
|
|
764
773
|
slot,
|
|
765
|
-
}), { session, onSlotFree: (id) => (slot = id) });
|
|
774
|
+
}), { session, onSlotFree: (id) => (slot = id), abandoned: () => abandoned });
|
|
766
775
|
logger?.info?.(`queued ${req.method ?? "POST"} ${req.url ?? "completion"} for ${model.displayName}; queue depth ${scheduler.stats().queued}`);
|
|
767
|
-
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
|
+
});
|
|
768
783
|
});
|
|
769
784
|
}
|
|
770
785
|
// The bench ranking is read from disk (one JSON per run). A completion request
|
|
@@ -806,6 +821,10 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
806
821
|
if (state === "starting") {
|
|
807
822
|
telemetry.reset();
|
|
808
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();
|
|
809
828
|
}
|
|
810
829
|
});
|
|
811
830
|
// GPU total VRAM is static hardware, so it is queried once at startup and
|
|
@@ -915,6 +934,23 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
915
934
|
const slots = supervisor.state === "ready"
|
|
916
935
|
? await sampleSlots({ host: supervisor.host, port: supervisor.internalPort }).catch(() => null)
|
|
917
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
|
+
}
|
|
918
954
|
return {
|
|
919
955
|
version,
|
|
920
956
|
// Additive, and separate from `version`: the package version says which
|
|
@@ -1071,13 +1107,13 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
|
|
|
1071
1107
|
let sawContent = false;
|
|
1072
1108
|
let sawReasoning = false;
|
|
1073
1109
|
if (isCompletion) {
|
|
1074
|
-
const
|
|
1110
|
+
const lease = reasoningTracker.begin();
|
|
1075
1111
|
// Released on close, not just on end: an aborted stream would
|
|
1076
1112
|
// otherwise pin `/__host/status` on "thinking" forever.
|
|
1077
|
-
const releaseReasoning = () =>
|
|
1113
|
+
const releaseReasoning = () => lease.end();
|
|
1078
1114
|
upstreamRes.on("data", (chunk) => {
|
|
1079
1115
|
const text = String(chunk);
|
|
1080
|
-
|
|
1116
|
+
lease.observe(text);
|
|
1081
1117
|
if (chunkHasContent(text))
|
|
1082
1118
|
sawContent = true;
|
|
1083
1119
|
if (chunkHasReasoning(text))
|
|
@@ -202,6 +202,20 @@ export interface SchedulerSubmitOptions {
|
|
|
202
202
|
* and their attribution is not per-slot.
|
|
203
203
|
*/
|
|
204
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;
|
|
205
219
|
}
|
|
206
220
|
/** A queued request or host operation bound to a resolved catalog model. */
|
|
207
221
|
export interface QueuedJob<TSupervisor extends SchedulerSupervisor = SchedulerSupervisor> {
|
|
@@ -212,6 +226,7 @@ export interface QueuedJob<TSupervisor extends SchedulerSupervisor = SchedulerSu
|
|
|
212
226
|
session: string | null;
|
|
213
227
|
onStart: (() => void) | null;
|
|
214
228
|
onSlotFree: ((slotId: number | null) => void) | null;
|
|
229
|
+
abandoned: (() => boolean) | null;
|
|
215
230
|
/**
|
|
216
231
|
* The engine slot this job was pinned to at admission, or null when none was
|
|
217
232
|
* named. Kept on the job so a later pass can exclude it from the ids it hands
|
|
@@ -275,7 +290,7 @@ export declare class Scheduler<TSupervisor extends SchedulerSupervisor = Schedul
|
|
|
275
290
|
* Dispatch is deferred a microtask so a burst of requests submitted together
|
|
276
291
|
* shares one turn rather than the first one taking a turn by itself.
|
|
277
292
|
*/
|
|
278
|
-
submit(model: Model, run: (supervisor: TSupervisor) => 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>;
|
|
279
294
|
/**
|
|
280
295
|
* Drop every recorded slot owner. The engine's slots do not survive a model
|
|
281
296
|
* (re)launch, so their owners do not either - a stale entry would make the
|
|
@@ -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,
|
|
@@ -182,6 +183,33 @@ _Scheduler_batch = new WeakMap(), _Scheduler_running = new WeakMap(), _Scheduler
|
|
|
182
183
|
if (taken.length > 0)
|
|
183
184
|
__classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_announce).call(this);
|
|
184
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);
|
|
185
213
|
}, _Scheduler_warmSessions = function _Scheduler_warmSessions() {
|
|
186
214
|
const warm = new Set();
|
|
187
215
|
for (const job of __classPrivateFieldGet(this, _Scheduler_running, "f"))
|
|
@@ -365,6 +393,11 @@ async function _Scheduler_pass() {
|
|
|
365
393
|
// the sample the first time capacity is checked; consumed in `#start`.
|
|
366
394
|
__classPrivateFieldSet(this, _Scheduler_freeSlotIds, null, "f");
|
|
367
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);
|
|
368
401
|
// An exclusive operation owns the engine alone.
|
|
369
402
|
if (this.activeJob !== null)
|
|
370
403
|
return;
|
package/package.json
CHANGED