@jmcombs/pi-steward 0.0.0

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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/core/disconnected-source.ts +110 -0
  4. package/core/drift.ts +247 -0
  5. package/core/format.ts +317 -0
  6. package/core/host-metrics.ts +121 -0
  7. package/core/llama-config.ts +72 -0
  8. package/core/llama-connection.ts +215 -0
  9. package/core/llama-models.ts +261 -0
  10. package/core/llama-slots.ts +104 -0
  11. package/core/llama-source.ts +1523 -0
  12. package/core/log-parse.ts +440 -0
  13. package/core/model-color.ts +59 -0
  14. package/core/select.ts +2923 -0
  15. package/core/slot-activity.ts +658 -0
  16. package/core/source.ts +84 -0
  17. package/core/state.ts +609 -0
  18. package/core/status-widget.ts +222 -0
  19. package/core/temperature.ts +149 -0
  20. package/core/types.ts +431 -0
  21. package/index.ts +503 -0
  22. package/package.json +51 -0
  23. package/server/api.ts +216 -0
  24. package/server/assets.ts +198 -0
  25. package/server/config-wiring.ts +490 -0
  26. package/server/drift-probe.ts +150 -0
  27. package/server/host-collector.ts +272 -0
  28. package/server/index.ts +228 -0
  29. package/server/log-tailer.ts +432 -0
  30. package/server/service-control.ts +337 -0
  31. package/server/service-probe.ts +71 -0
  32. package/server/steward-config.ts +430 -0
  33. package/setup/init-prompt.ts +214 -0
  34. package/setup/steward-setup.d.mts +16 -0
  35. package/setup/steward-setup.mjs +1398 -0
  36. package/ui/components/console.ts +511 -0
  37. package/ui/components/gauges.ts +120 -0
  38. package/ui/components/metrics.ts +63 -0
  39. package/ui/components/models.ts +296 -0
  40. package/ui/components/service.ts +358 -0
  41. package/ui/components/slots.ts +114 -0
  42. package/ui/components/sparkline.ts +59 -0
  43. package/ui/components/toolbar.ts +211 -0
  44. package/ui/dom.ts +120 -0
  45. package/ui/favicon.svg +17 -0
  46. package/ui/index.html +34 -0
  47. package/ui/main.ts +678 -0
  48. package/ui/steward.css +2008 -0
@@ -0,0 +1,658 @@
1
+ /**
2
+ * Slot occupancy, derived from the server's own log instead of polled.
3
+ *
4
+ * `/slots?model=X` and `/metrics?model=X` are per-model, so the router proxies
5
+ * each one to the child and writes a `proxy_reques:` line for it. Asking both,
6
+ * for every loaded model, on the dashboard's 1.6 s repaint clock made Steward
7
+ * the single loudest writer in the log it exists to display — a measured 86.9%
8
+ * of one real corpus, ~1 line per second, most of it Steward watching itself.
9
+ *
10
+ * It was also *sampled*, and that is the part an operator actually noticed: a
11
+ * request shorter than the poll interval begins and ends between two reads and
12
+ * is never seen at all. On the measured workload — 11,343 requests averaging
13
+ * ~0.9 s against a 1.6 s clock — the slots panel read idle while the server was
14
+ * working. (Under continuous load the same panel reads busy every time, which is
15
+ * how we know it is a sampling limit and not a regression.)
16
+ *
17
+ * The log has the answer already, is already being tailed, and costs nothing
18
+ * more to read. Every `SLT_*` macro emits one shared, byte-stable frame —
19
+ * `id %2d | task %d | ` — which the parser has already lifted off the message,
20
+ * and the events that bracket a request are unambiguous:
21
+ *
22
+ * ```
23
+ * slot get_availabl: id 0 | task -1 | selected slot by LCP similarity, …
24
+ * slot launch_slot_: id 0 | task 836989 | processing task, is_child = 0
25
+ * slot print_timing: id 0 | task 836989 | prompt processing, n_tokens = 16385, progress = 0.85, …
26
+ * slot print_timing: id 0 | task 836989 | n_decoded = 663, tg = 110.43 t/s, tg_3s = 109.88 t/s
27
+ * slot print_timing: id 0 | task 836989 | eval time = 697.41 ms / 90 tokens (…, 129.05 tokens per second)
28
+ * slot release: id 0 | task 836989 | stop processing: n_tokens = 163, truncated = 0
29
+ * ```
30
+ *
31
+ * Two shapes in there are worth calling out, because a parser written from the
32
+ * llama.cpp source rather than from a real log gets both wrong. The function
33
+ * name is printed right-aligned in a twelve-wide field, so it is
34
+ * `slot release:` with five leading spaces and never `slot release:` — but
35
+ * that is the *frame's* problem, not this module's: every rule below reads the
36
+ * post-frame message, where the payload is. And `prompt eval time = …` (prefill)
37
+ * sits one word away from `eval time = …` (generation); only the second is a
38
+ * generation rate, so the generation rule is anchored to the start of the
39
+ * message where the word `prompt` cannot precede it.
40
+ *
41
+ * The same lines also say how much the server GENERATED, and that is a different
42
+ * reading from occupancy. `n_decoded` on a live readout and the `N tokens` an
43
+ * `eval time` line prints are both counts of tokens produced, so they are
44
+ * ledgered here as TOKENS rather than kept as a rate. A rate cannot be kept: on
45
+ * the corpus above, `eval time` is printed 17 microseconds before the `release`
46
+ * that ends the request, and a reader sampling every 1.6 s can only see a
47
+ * reading that exists for 17 µs by landing inside it. Tokens survive the gap —
48
+ * {@link SlotActivity.takeGeneratedTokens} hands each one to exactly one reader,
49
+ * exactly once — and tokens over wall clock is what throughput means anyway.
50
+ *
51
+ * What this module deliberately does NOT do is invent the parts the log does not
52
+ * carry. `requests_deferred` — the queue behind the slots — has no log line at
53
+ * all, so nothing here reports it. How many slots a model has and how large each
54
+ * one's context is are *structure*, not occupancy: they come from `/v1/models`,
55
+ * which the router answers itself and logs nothing for.
56
+ *
57
+ * Keep this module free of Node and DOM APIs — see `./types.ts`. It holds no
58
+ * clock either: every time it needs one it is given the caller's.
59
+ */
60
+
61
+ import type { LogLine, SlotState } from "./types.js";
62
+
63
+ /** One slot's occupancy as the event stream last established it. */
64
+ export interface SlotActivityState {
65
+ /** Per-model slot index, from 0. */
66
+ slot: number;
67
+ state: SlotState;
68
+ /** The task the slot is running, or `null` when it holds none. */
69
+ task: number | null;
70
+ /** Tokens the slot's context holds, or `null` when nothing has said. */
71
+ promptTokens: number | null;
72
+ /** Tokens generated so far this turn, or `null` while unmeasured. */
73
+ decoded: number | null;
74
+ /**
75
+ * Measured generation rate for the request in this slot, or `null`. Only ever
76
+ * a reading: it is cleared when a request starts and when one ends, so it can
77
+ * never outlive the request it was measured for.
78
+ */
79
+ rateTps: number | null;
80
+ }
81
+
82
+ /** One slot of a one-shot `/slots` read, as {@link SlotActivity.applySeed} takes it. */
83
+ export interface SlotActivitySeed {
84
+ slot: number;
85
+ /** What the read said, `unknown` included — a body with no `is_processing`
86
+ * establishes nothing, and seeding it as idle would invent the answer. */
87
+ state: SlotState;
88
+ promptTokens: number | null;
89
+ decoded: number | null;
90
+ }
91
+
92
+ /**
93
+ * How many one-shot `/slots` reads a port may spend establishing itself before
94
+ * Steward gives up and waits for the log to say something. Bounded so a router
95
+ * that answers `/slots` with an error can never turn the seed into the poll it
96
+ * replaced.
97
+ */
98
+ export const MAX_SEED_ATTEMPTS = 3;
99
+
100
+ /**
101
+ * How long a slot may sit `processing` with nothing further said about it before
102
+ * its state is treated as `unknown` rather than believed.
103
+ *
104
+ * This is the answer to a missed `release`. A stream can lose lines — the tailer
105
+ * re-anchors when the file is replaced or truncated, a restarted Steward starts
106
+ * from a backlog window, `com.apple.tmp_cleaner` deletes the file outright — and
107
+ * a `release` in the lost window would otherwise leave a slot busy forever.
108
+ *
109
+ * **A running request does NOT reliably re-arm this, and nothing here should be
110
+ * written as though it does.** llama.cpp's `print_timings_tg()` is gated on BOTH
111
+ * `n_decoded >= 100` AND ~3 s elapsed, so a generation that is slow but short —
112
+ * 60 tokens at 0.4 t/s is 150 s of work — emits no running readout at all and
113
+ * crosses this bound in complete silence. The `tg_3s` line only covers requests
114
+ * that are long in TOKENS, which is a strict subset of the requests that are long
115
+ * in TIME.
116
+ *
117
+ * What actually protects a long request is the seed. Crossing the bound makes
118
+ * {@link SlotActivity.needsSeed} true, and the caller's `needsSeed` check runs
119
+ * BEFORE its `resolve` in the same snapshot — so a one-shot `/slots` read lands
120
+ * first, answers `is_processing: true`, and restores `processing` with a fresh
121
+ * timestamp. The demotion is real but never reaches the dashboard. Delete that
122
+ * seed fallback believing this bound is self-correcting and a slow short
123
+ * generation starts misreporting as `unknown` — the two mechanisms are one
124
+ * design, not a mechanism and a redundancy.
125
+ *
126
+ * Resolving to `unknown` — never to `idle` — is what keeps the fallback honest
127
+ * when the seed cannot answer either: a timeout is evidence that we lost track,
128
+ * not evidence that the request finished.
129
+ */
130
+ export const SLOT_STALE_MS = 120_000;
131
+
132
+ export interface SlotActivity {
133
+ /**
134
+ * Folds one log line into slot state. Lines that are not a slot event — no
135
+ * `[port]` prefix, no pipe frame, a payload none of the rules match — are
136
+ * ignored, which is most of the log.
137
+ */
138
+ observe(line: LogLine): void;
139
+ /**
140
+ * Whether a one-shot `/slots` read is warranted for `port`: it has never been
141
+ * established, a lane went stale, or a lane is still unresolved. False once the
142
+ * port is settled and its budget is spent.
143
+ *
144
+ * `expectedLanes` is what the model's `--parallel` says it has, or `null` when
145
+ * that is not stated. The tracker cannot know it on its own — it only learns a
146
+ * lane exists when the log mentions one — so a `--parallel 4` model whose
147
+ * traffic all lands in lane 0 looks fully settled from in here. Passing the
148
+ * declared count is what lets lanes 1–3 be recognised as never established.
149
+ *
150
+ * This is never a timer. A never-seen port costs at most
151
+ * {@link MAX_SEED_ATTEMPTS} reads; after that a port with unresolved lanes is
152
+ * retried no more often than once per {@link SLOT_STALE_MS}, which is ~75×
153
+ * slower than the snapshot clock this replaced.
154
+ */
155
+ needsSeed(port: number, now: number, expectedLanes?: number | null): boolean;
156
+ /**
157
+ * Records that a seed read is being issued and returns the watermark to stamp
158
+ * it with — the sequence number of the last line already folded in. The read
159
+ * is asynchronous, so events can land while it is in flight; passing this back
160
+ * to {@link applySeed} is what stops an older HTTP answer overwriting a newer
161
+ * event.
162
+ */
163
+ beginSeed(port: number, now: number): number;
164
+ /**
165
+ * Applies a completed seed read, slot by slot, skipping any slot a log event
166
+ * has spoken for since `watermark`. Marks the port established.
167
+ */
168
+ applySeed(port: number, watermark: number, slots: readonly SlotActivitySeed[], now: number): void;
169
+ /**
170
+ * This port's slots, keyed by slot index, with staleness applied as of `now`.
171
+ * A port nothing is known about yields an empty map — the caller renders its
172
+ * model's slots `unknown`, which is what it is.
173
+ */
174
+ resolve(port: number, now: number): ReadonlyMap<number, SlotActivityState>;
175
+ /**
176
+ * Tokens the log has reported generated since the last call, across every
177
+ * port, and 0 afterwards — reading the ledger clears it.
178
+ *
179
+ * This is a COUNT, not a rate, and it is drained rather than read for a
180
+ * reason. The rate a completed request prints exists for the 17 microseconds
181
+ * between its `eval time` line and its `release`; a sampler on any realistic
182
+ * clock will miss it, and a sampler that held it instead would be showing a
183
+ * finished request's speed as if it were the server's current one. A count
184
+ * cannot go stale and cannot be double-counted: whichever reader asks next
185
+ * gets the tokens, and no later reader gets them again.
186
+ *
187
+ * Both readings feed it, so nothing is counted twice and nothing is missed. A
188
+ * long request's live `n_decoded` readouts are ledgered as they arrive, by
189
+ * difference, and its `eval time` total contributes only the tail that no
190
+ * readout covered. A short request — 99.4% of one measured 16,517-request
191
+ * corpus generated under 100 tokens, below llama.cpp's live-readout threshold
192
+ * — has no readouts at all, so its `eval time` total is the whole of it.
193
+ */
194
+ takeGeneratedTokens(): number;
195
+ /**
196
+ * Forgets every port not in `ports` — a child that exited. Its slot ids and
197
+ * task ids belong to a process that no longer exists, and a model reloaded on
198
+ * a fresh port must not inherit them.
199
+ *
200
+ * The token ledger is NOT touched: those tokens were generated, and the model
201
+ * having since unloaded does not make that less true.
202
+ */
203
+ retain(ports: Iterable<number>): void;
204
+ /**
205
+ * Declares the event stream discontinuous: every slot goes `unknown`, every
206
+ * port becomes eligible to be seeded again, and the token ledger is dropped.
207
+ * For a tailer that reconnected, a log file that came back, or any other break
208
+ * across which state cannot be carried.
209
+ *
210
+ * The ledger goes with it because the per-slot running totals go with it. A
211
+ * request in flight across the break would otherwise report its `eval time`
212
+ * total against a slot that no longer remembers how much of it was already
213
+ * counted, and the tokens counted before the break would be counted a second
214
+ * time after it. Dropping both halves is the only answer that invents nothing.
215
+ */
216
+ resync(): void;
217
+ }
218
+
219
+ /** `processing task, is_child = 0` — the slot took a task. */
220
+ const LAUNCH = /^processing task\b/;
221
+
222
+ /** `stop processing: n_tokens = 165, truncated = 0` — the slot gave it back. */
223
+ const RELEASE = /^stop processing\b/;
224
+
225
+ /** The `n_tokens = N` a release reports: the context the slot now holds. */
226
+ const RELEASE_TOKENS = /\bn_tokens\s*=\s*(\d+)/;
227
+
228
+ /**
229
+ * `n_decoded = 663, tg = 110.43 t/s, tg_3s = 109.88 t/s` — the in-flight readout,
230
+ * and the only live generation rate the log carries. See {@link SLOT_STALE_MS}
231
+ * for what it does NOT cover: it needs 100 decoded tokens as well as ~3 s.
232
+ *
233
+ * Two rates are printed and they are not the same measurement. `tg` is the mean
234
+ * since the request began; `tg_3s` is the last ~3 seconds. A tile that says what
235
+ * the server is doing *now* wants the second, so it is preferred and `tg` is the
236
+ * fallback for a build that prints only the one. On the first readout of a
237
+ * request the two are equal, so nothing jumps when the window fills.
238
+ */
239
+ const LIVE_DECODED = /\bn_decoded\s*=\s*(\d+)/;
240
+ const LIVE_TG_3S = /\btg_3s\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*t\/s/;
241
+ const LIVE_TG = /\btg\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*t\/s/;
242
+
243
+ /**
244
+ * ` eval time = 697.41 ms / 90 tokens (…, 129.05 tokens per second)`
245
+ * — the completed request's generation timing.
246
+ *
247
+ * Anchored at the start of the message so `prompt eval time = …`, which is
248
+ * prefill and not a generation rate, cannot match it.
249
+ */
250
+ const EVAL_TIMING =
251
+ /^\s*eval time\s*=\s*[0-9.]+\s*ms\s*\/\s*(\d+)\s*tokens\s*\([^)]*?([0-9]+(?:\.[0-9]+)?)\s*tokens per second/;
252
+
253
+ /**
254
+ * `prompt processing, n_tokens = 16385, progress = 0.85, t = 13.94 s / 1175.06 tokens per second`
255
+ * — live prefill progress. Rare (long prompts only), so nothing depends on it;
256
+ * when it is there it is the one thing that reports a slot's context filling up
257
+ * while the request is still running.
258
+ */
259
+ const PROMPT_PROGRESS = /^\s*prompt processing,\s*n_tokens\s*=\s*(\d+)/;
260
+
261
+ /** One slot's tracked state, plus the bookkeeping that keeps it honest. */
262
+ interface SlotRecord {
263
+ state: SlotState;
264
+ task: number | null;
265
+ promptTokens: number | null;
266
+ decoded: number | null;
267
+ rateTps: number | null;
268
+ /**
269
+ * Tokens of the request CURRENTLY in this slot that the ledger has already
270
+ * been given, so a second reading of the same request contributes only what is
271
+ * new. Reset every time the slot changes hands, which is what stops one
272
+ * request's total being subtracted from the next one's.
273
+ */
274
+ counted: number;
275
+ /** `seq` of the last log line that spoke for this slot; 0 when only seeded. */
276
+ lastSeq: number;
277
+ /** When this record was last established, for the staleness bound. */
278
+ updatedAt: number;
279
+ }
280
+
281
+ interface PortRecord {
282
+ slots: Map<number, SlotRecord>;
283
+ seedAttempts: number;
284
+ seeded: boolean;
285
+ /** Whether the current run of staleness has already refreshed the budget. */
286
+ staleHandled: boolean;
287
+ /** When the last seed read was issued, so a retry can be paced off the clock. */
288
+ lastAttemptAt: number | null;
289
+ }
290
+
291
+ function blankRecord(now: number): SlotRecord {
292
+ return {
293
+ state: "unknown",
294
+ task: null,
295
+ promptTokens: null,
296
+ decoded: null,
297
+ rateTps: null,
298
+ counted: 0,
299
+ lastSeq: 0,
300
+ updatedAt: now,
301
+ };
302
+ }
303
+
304
+ /** A capture group as a finite number, or `null`. */
305
+ function toNumber(raw: string | undefined): number | null {
306
+ if (raw === undefined) return null;
307
+ const value = Number(raw);
308
+ return Number.isFinite(value) ? value : null;
309
+ }
310
+
311
+ /**
312
+ * Tracks slot occupancy across every model the router runs, keyed by the child's
313
+ * port.
314
+ *
315
+ * The port is the identity, not the model id: task ids and slot ids are
316
+ * per-process counters that restart at 0 in every child, so a model unloaded and
317
+ * reloaded gets a fresh port and, with it, a clean slate. Joining port to model
318
+ * is the caller's job — it already has `/v1/models`, which states each loaded
319
+ * child's `--port`.
320
+ */
321
+ export function createSlotActivity(): SlotActivity {
322
+ const ports = new Map<number, PortRecord>();
323
+
324
+ /** The highest `seq` folded in, so a seed read can be stamped against it. */
325
+ let watermark = 0;
326
+
327
+ /** Tokens reported since the ledger was last drained. See {@link SlotActivity.takeGeneratedTokens}. */
328
+ let generated = 0;
329
+
330
+ function port(number: number): PortRecord {
331
+ const existing = ports.get(number);
332
+ if (existing !== undefined) return existing;
333
+ const created: PortRecord = {
334
+ slots: new Map(),
335
+ seedAttempts: 0,
336
+ seeded: false,
337
+ staleHandled: false,
338
+ lastAttemptAt: null,
339
+ };
340
+ ports.set(number, created);
341
+ return created;
342
+ }
343
+
344
+ function slot(record: PortRecord, id: number, now: number): SlotRecord {
345
+ const existing = record.slots.get(id);
346
+ if (existing !== undefined) return existing;
347
+ const created = blankRecord(now);
348
+ record.slots.set(id, created);
349
+ return created;
350
+ }
351
+
352
+ /**
353
+ * Ledgers the part of `total` this slot has not been credited with yet.
354
+ *
355
+ * Every reading llama.cpp prints is cumulative FOR ITS REQUEST — `n_decoded`
356
+ * counts up while the request runs and `eval time`'s token count is where it
357
+ * finished — so the ledger takes differences, never the figure itself. A
358
+ * reading for a task other than the one the slot was following starts a fresh
359
+ * count rather than a negative one: that happens when a `launch` was lost, and
360
+ * the new request's tokens are its own, not this slot's previous request's.
361
+ *
362
+ * A slot holding NO task is left alone, because that is the seed's state: a
363
+ * `/slots` read says how far along a request is without naming it, and its
364
+ * figure is exactly the part of the request that must not be counted. Treating
365
+ * "no task named" as "a different task" would throw that credit away and hand
366
+ * the whole of a request Steward watched only the end of to one window.
367
+ *
368
+ * `total` moving backwards or not moving contributes nothing, so a repeated or
369
+ * out-of-order line can only ever add zero.
370
+ */
371
+ function count(entry: SlotRecord, task: number, total: number | null): void {
372
+ if (entry.task !== null && entry.task !== task) entry.counted = 0;
373
+ if (total === null || total <= entry.counted) return;
374
+ generated += total - entry.counted;
375
+ entry.counted = total;
376
+ }
377
+
378
+ /**
379
+ * Whether a lane has been `processing` past the bound. This is the edge that
380
+ * reopens a settled port for another seed read, and it is why a missed
381
+ * `release` self-heals instead of pinning a chip on `busy` forever.
382
+ */
383
+ function hasStale(record: PortRecord, now: number): boolean {
384
+ for (const entry of record.slots.values()) {
385
+ if (entry.state === "processing" && now - entry.updatedAt > SLOT_STALE_MS) return true;
386
+ }
387
+ return false;
388
+ }
389
+
390
+ /**
391
+ * Whether any lane's occupancy is still not established — one the log has
392
+ * never mentioned, or one explicitly `unknown`.
393
+ *
394
+ * Staleness alone is not enough to catch this. A lane that is `unknown`
395
+ * (because every seed read failed while the router was unwell) is not
396
+ * `processing`, so it never trips the stale edge, and llama.cpp's LCP slot
397
+ * affinity will happily send every request to lane 0 for hours — so no event
398
+ * ever names lanes 1–3 either. Without this check those lanes sit `unknown`
399
+ * for the life of the child process with nothing left that could ever ask.
400
+ */
401
+ function hasUnresolved(record: PortRecord, expectedLanes: number | null): boolean {
402
+ for (const entry of record.slots.values()) {
403
+ if (entry.state === "unknown") return true;
404
+ }
405
+ if (expectedLanes === null) return false;
406
+ for (let id = 0; id < expectedLanes; id += 1) {
407
+ if (!record.slots.has(id)) return true;
408
+ }
409
+ return false;
410
+ }
411
+
412
+ return {
413
+ observe(line: LogLine): void {
414
+ if (line.seq > watermark) watermark = line.seq;
415
+
416
+ // A slot event is a child line carrying the SLT_* pipe frame. Everything
417
+ // else in the log — the router's own lines, the boot banners, the proxy
418
+ // records, a child's vocab warnings — has nothing to say about occupancy.
419
+ const frame = line.frame;
420
+ if (frame === undefined || line.port === undefined) return;
421
+ // A frame llama.cpp emitted with no real slot (a future "none available"
422
+ // path) is not a slot we can track, and tracking it as slot -1 would put a
423
+ // phantom lane on a chip.
424
+ if (frame.slot < 0) return;
425
+
426
+ const record = port(line.port);
427
+ const message = line.message;
428
+
429
+ // `get_available_slot` runs before a task is attached, which is why its
430
+ // frame reads `task -1` — and it only ever names a slot it found FREE. So
431
+ // this line is a direct, exact idle observation, and the cheapest re-sync
432
+ // the log offers: if we thought the slot was busy, its release was one of
433
+ // the lines we lost.
434
+ if (frame.task === -1) {
435
+ const entry = slot(record, frame.slot, line.ts);
436
+ entry.state = "idle";
437
+ entry.task = null;
438
+ entry.decoded = null;
439
+ entry.rateTps = null;
440
+ entry.counted = 0;
441
+ entry.lastSeq = line.seq;
442
+ entry.updatedAt = line.ts;
443
+ return;
444
+ }
445
+
446
+ if (LAUNCH.test(message)) {
447
+ const entry = slot(record, frame.slot, line.ts);
448
+ // A launch for a different task than the one we hold means that one's
449
+ // release was missed. The new task is the truth; nothing of the old
450
+ // one's readings survives into it.
451
+ entry.state = "processing";
452
+ entry.task = frame.task;
453
+ entry.decoded = null;
454
+ entry.rateTps = null;
455
+ entry.counted = 0;
456
+ entry.lastSeq = line.seq;
457
+ entry.updatedAt = line.ts;
458
+ return;
459
+ }
460
+
461
+ if (RELEASE.test(message)) {
462
+ const entry = slot(record, frame.slot, line.ts);
463
+ entry.state = "idle";
464
+ entry.task = null;
465
+ // `n_tokens` here is the context the slot is left holding, and it stays
466
+ // true until the next request reuses or evicts it — so it is the idle
467
+ // slot's occupancy, not a leftover from a request that ended.
468
+ entry.promptTokens = toNumber(RELEASE_TOKENS.exec(message)?.[1]) ?? entry.promptTokens;
469
+ entry.decoded = null;
470
+ entry.rateTps = null;
471
+ // The request is over and its tokens are already in the ledger — the
472
+ // `eval time` line that precedes this one by microseconds put them
473
+ // there. What resets is only the running total, so the next request in
474
+ // this slot is counted from zero.
475
+ entry.counted = 0;
476
+ entry.lastSeq = line.seq;
477
+ entry.updatedAt = line.ts;
478
+ return;
479
+ }
480
+
481
+ const decoded = LIVE_DECODED.exec(message);
482
+ // The last ~3 seconds if the build prints it, else the mean since the
483
+ // request began — both are measurements of this request, and neither is
484
+ // ever carried past its release.
485
+ const tg = LIVE_TG_3S.exec(message) ?? LIVE_TG.exec(message);
486
+ if (decoded !== null && tg !== null) {
487
+ // A running readout is also proof of occupancy: whatever we thought, this
488
+ // slot is generating right now, on this task.
489
+ const entry = slot(record, frame.slot, line.ts);
490
+ const total = toNumber(decoded[1]);
491
+ // Ledgered before the task is stamped on: `count` needs to know whether
492
+ // this reading continues the request the slot was already following.
493
+ count(entry, frame.task, total);
494
+ entry.state = "processing";
495
+ entry.task = frame.task;
496
+ entry.decoded = total;
497
+ entry.rateTps = toNumber(tg[1]);
498
+ entry.lastSeq = line.seq;
499
+ entry.updatedAt = line.ts;
500
+ return;
501
+ }
502
+
503
+ const timing = EVAL_TIMING.exec(message);
504
+ if (timing !== null) {
505
+ // The completed request's own rate, printed microseconds before its
506
+ // release. It counts while the slot is still holding the task and is
507
+ // cleared by the release that follows — it is never carried into the
508
+ // next request, or into the idle gap after this one. The TOKENS on the
509
+ // same line outlive it: they go to the ledger, where the throughput the
510
+ // dashboard reports is measured from tokens over wall clock rather than
511
+ // from a rate that only exists for the microseconds before the release.
512
+ const entry = slot(record, frame.slot, line.ts);
513
+ const total = toNumber(timing[1]);
514
+ count(entry, frame.task, total);
515
+ entry.state = "processing";
516
+ entry.task = frame.task;
517
+ entry.decoded = total;
518
+ entry.rateTps = toNumber(timing[2]);
519
+ entry.lastSeq = line.seq;
520
+ entry.updatedAt = line.ts;
521
+ return;
522
+ }
523
+
524
+ const progress = PROMPT_PROGRESS.exec(message);
525
+ if (progress !== null) {
526
+ const entry = slot(record, frame.slot, line.ts);
527
+ entry.state = "processing";
528
+ entry.task = frame.task;
529
+ entry.promptTokens = toNumber(progress[1]);
530
+ entry.lastSeq = line.seq;
531
+ entry.updatedAt = line.ts;
532
+ }
533
+ },
534
+
535
+ needsSeed(number: number, now: number, expectedLanes: number | null = null): boolean {
536
+ const record = ports.get(number);
537
+ if (record === undefined) return true;
538
+
539
+ // A lane we have lost track of means the stream and reality have come
540
+ // apart, so the port is worth establishing again — and it gets a fresh
541
+ // budget, because this is new uncertainty and not a retry of the old one.
542
+ //
543
+ // The budget is reset on the EDGE, once, and not while the condition
544
+ // persists. Resetting it on every call would hand a port whose `/slots`
545
+ // read keeps failing an unlimited retry on the snapshot clock, which is
546
+ // the polling loop this change removed, rebuilt by accident.
547
+ const stale = hasStale(record, now);
548
+ if (stale && !record.staleHandled) {
549
+ record.staleHandled = true;
550
+ record.seeded = false;
551
+ record.seedAttempts = 0;
552
+ } else if (!stale) {
553
+ record.staleHandled = false;
554
+ }
555
+
556
+ if (!record.seeded && record.seedAttempts < MAX_SEED_ATTEMPTS) return true;
557
+
558
+ // The budget is spent, but a lane is still unresolved and no event is
559
+ // coming for it. Try again — paced by the staleness bound, so this can
560
+ // never approach the cadence it replaced. At worst a permanently
561
+ // unresolvable port costs MAX_SEED_ATTEMPTS reads every SLOT_STALE_MS
562
+ // (~1 read per 40 s), against the ~2 per 1.6 s the old path spent on a
563
+ // model that was working perfectly.
564
+ if (!hasUnresolved(record, expectedLanes)) return false;
565
+ const since = record.lastAttemptAt;
566
+ if (since !== null && now - since < SLOT_STALE_MS) return false;
567
+ record.seeded = false;
568
+ record.seedAttempts = 0;
569
+ return true;
570
+ },
571
+
572
+ beginSeed(number: number, now: number): number {
573
+ const record = port(number);
574
+ record.seedAttempts += 1;
575
+ record.lastAttemptAt = now;
576
+ return watermark;
577
+ },
578
+
579
+ applySeed(
580
+ number: number,
581
+ stamp: number,
582
+ slots: readonly SlotActivitySeed[],
583
+ now: number,
584
+ ): void {
585
+ const record = port(number);
586
+ record.seeded = true;
587
+ record.seedAttempts = 0;
588
+ for (const seeded of slots) {
589
+ if (seeded.slot < 0) continue;
590
+ const entry = slot(record, seeded.slot, now);
591
+ // An event that landed while the read was in flight is newer than the
592
+ // read and wins. Without this the answer to a 4 s HTTP call could undo
593
+ // four seconds of live events.
594
+ if (entry.lastSeq > stamp) continue;
595
+ entry.state = seeded.state;
596
+ entry.task = null;
597
+ entry.promptTokens = seeded.promptTokens;
598
+ entry.decoded = seeded.decoded;
599
+ // A request already in flight when Steward attached has generated tokens
600
+ // nobody here watched it generate. Crediting the seed's `n_decoded` as
601
+ // already counted is what stops its eventual `eval time` total landing
602
+ // in the ledger as if every one of those tokens had been produced in the
603
+ // window that happened to catch the end of it.
604
+ entry.counted = seeded.decoded ?? 0;
605
+ // A rate is never seeded: `/slots` does not carry one, and the
606
+ // `/metrics` gauge that does holds its last value after generation ends,
607
+ // which is exactly the stale number this change exists to stop showing.
608
+ entry.rateTps = null;
609
+ entry.updatedAt = now;
610
+ }
611
+ },
612
+
613
+ resolve(number: number, now: number): ReadonlyMap<number, SlotActivityState> {
614
+ const record = ports.get(number);
615
+ if (record === undefined) return new Map();
616
+ const resolved = new Map<number, SlotActivityState>();
617
+ for (const [id, entry] of record.slots) {
618
+ // Past the bound we have not been told this slot is still working; we
619
+ // have only not been told that it stopped. Those are different, and only
620
+ // one of them is `processing`.
621
+ const stale = entry.state === "processing" && now - entry.updatedAt > SLOT_STALE_MS;
622
+ resolved.set(id, {
623
+ slot: id,
624
+ state: stale ? "unknown" : entry.state,
625
+ task: stale ? null : entry.task,
626
+ promptTokens: entry.promptTokens,
627
+ decoded: stale ? null : entry.decoded,
628
+ rateTps: stale ? null : entry.rateTps,
629
+ });
630
+ }
631
+ return resolved;
632
+ },
633
+
634
+ takeGeneratedTokens(): number {
635
+ const total = generated;
636
+ generated = 0;
637
+ return total;
638
+ },
639
+
640
+ retain(keep: Iterable<number>): void {
641
+ const live = new Set(keep);
642
+ for (const number of [...ports.keys()]) {
643
+ if (!live.has(number)) ports.delete(number);
644
+ }
645
+ },
646
+
647
+ resync(): void {
648
+ generated = 0;
649
+ for (const record of ports.values()) {
650
+ record.seeded = false;
651
+ record.seedAttempts = 0;
652
+ record.staleHandled = false;
653
+ record.lastAttemptAt = null;
654
+ record.slots.clear();
655
+ }
656
+ },
657
+ };
658
+ }