@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.
@@ -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/candidate-models.md. `--list-quants` shows what
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
- * Record the engine slot this request was pinned to. Called exactly once per
104
- * request, at dispatch - the pin is injected into the outbound body before
105
- * the request goes out, so the association exists before the first chunk and
106
- * `observe` never has to learn about it.
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
- * Idempotent and self-cleaning: a repeat for the same slot is a no-op, and a
109
- * different slot replaces it, so a request that somehow moves slots (a
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
- setSlot(requestId: string, slotId: number): void;
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. */
@@ -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, _ReasoningTracker_slots, _ReasoningTracker_tails, _ReasoningTracker_inlineReasoning, _ReasoningTracker_listeners, _ReasoningTracker_lastSnapshot, _ReasoningTracker_announce;
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, "0:0:0:0");
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
- * Record the engine slot this request was pinned to. Called exactly once per
233
- * request, at dispatch - the pin is injected into the outbound body before
234
- * the request goes out, so the association exists before the first chunk and
235
- * `observe` never has to learn about it.
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
- * Idempotent and self-cleaning: a repeat for the same slot is a no-op, and a
238
- * different slot replaces it, so a request that somehow moves slots (a
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
- setSlot(requestId, slotId) {
242
- if (!Number.isInteger(slotId) || slotId < 0)
243
- return;
244
- if (__classPrivateFieldGet(this, _ReasoningTracker_slots, "f").get(requestId) === slotId)
245
- return;
246
- __classPrivateFieldGet(this, _ReasoningTracker_slots, "f").set(requestId, slotId);
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
- /** Note a chunk of `requestId`'s stream. Cheap enough to call per chunk. */
250
- observe(requestId, text) {
251
- const current = __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").get(requestId);
252
- if (current === "generating")
253
- return;
254
- if (!current)
255
- __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "processing");
256
- // Node can split an SSE JSON field name at any byte. Keeping a small tail
257
- // makes stage recognition independent of transport chunk boundaries without
258
- // parsing or retaining the generated content itself.
259
- const combined = `${__classPrivateFieldGet(this, _ReasoningTracker_tails, "f").get(requestId) ?? ""}${text}`;
260
- __classPrivateFieldGet(this, _ReasoningTracker_tails, "f").set(requestId, combined.slice(-128));
261
- if (__classPrivateFieldGet(this, _ReasoningTracker_inlineReasoning, "f").has(requestId)) {
262
- if (combined.includes("</think>")) {
263
- __classPrivateFieldGet(this, _ReasoningTracker_inlineReasoning, "f").delete(requestId);
264
- __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "generating");
265
- __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
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 (chunkHasReasoning(combined) && current !== "thinking") {
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
- /** Forget the request. Must be called on end *and* on error, or the flag sticks. */
286
- end(requestId) {
287
- __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").delete(requestId);
288
- __classPrivateFieldGet(this, _ReasoningTracker_slots, "f").delete(requestId);
289
- __classPrivateFieldGet(this, _ReasoningTracker_tails, "f").delete(requestId);
290
- __classPrivateFieldGet(this, _ReasoningTracker_inlineReasoning, "f").delete(requestId);
291
- __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
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 [requestId, stage] of __classPrivateFieldGet(this, _ReasoningTracker_requests, "f")) {
309
- result[stage] += 1;
310
- const slot = __classPrivateFieldGet(this, _ReasoningTracker_slots, "f").get(requestId);
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(slot)] = stage;
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(), _ReasoningTracker_slots = new WeakMap(), _ReasoningTracker_tails = new WeakMap(), _ReasoningTracker_inlineReasoning = new WeakMap(), _ReasoningTracker_listeners = new WeakMap(), _ReasoningTracker_lastSnapshot = new WeakMap(), _ReasoningTracker_instances = new WeakSet(), _ReasoningTracker_announce = function _ReasoningTracker_announce() {
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
@@ -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
- 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;
442
436
  const done = () => {
443
- // Always release the reasoning flag, including on the error and abort
444
- // 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
445
439
  // "thinking" until the service restarts.
446
- reasoning?.end(streamId);
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
- 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();
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
- reasoning?.observe(streamId, text);
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
- // This is the first authoritative inference-stage signal: the request is
584
- // being dispatched to llama-server and is waiting for prompt processing or
585
- // its first output delta. The slot association is set once here, at the
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
- reasoning?.setSlot(streamId, slotId);
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) => 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
+ });
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 streamId = nextStreamId();
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 = () => reasoningTracker.end(streamId);
1113
+ const releaseReasoning = () => lease.end();
1078
1114
  upstreamRes.on("data", (chunk) => {
1079
1115
  const text = String(chunk);
1080
- reasoningTracker.observe(streamId, text);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otto-code/brain",
3
- "version": "0.8.13",
3
+ "version": "0.8.14",
4
4
  "description": "Otto Brain - self-contained host for local GGUF models, with measured VRAM budgeting and reasoning-budget control",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "bin": {