flowviant 0.81.0 → 0.84.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.
@@ -14,12 +14,44 @@
14
14
  * run 2 more tasks" is the capacity dial wearing a lab coat, and that is dead.
15
15
  * The only surface for this is project settings, whose audience is whoever
16
16
  * administers the box.
17
+ *
18
+ * ── AND SINCE 2026-09-14 THERE IS A SECOND READER, WHICH IS NOT TELEMETRY ──
19
+ *
20
+ * The paragraph above is about the SNAPSHOT and stands unchanged. What is added
21
+ * below — `memAvailableBytes` and `pressureVerdict` — is read at the moment
22
+ * this machine is about to spawn one more CLI, and it exists because it froze
23
+ * somebody's computer: `MAX_CONCURRENT` had been enforced nowhere since
24
+ * dispatch was deleted, the session-turn lane had no slice at all, and nothing
25
+ * anywhere looked at memory before starting a process that routinely holds
26
+ * gigabytes.
27
+ *
28
+ * It is a RUNAWAY BOUND ON A MACHINE, in the same family as the open-tab
29
+ * ceiling and the card-write budget, and the distinction from a headroom meter
30
+ * is exact and worth stating because the two look alike from a distance:
31
+ *
32
+ * · a HEADROOM METER is read AHEAD of the decision, by the person making it,
33
+ * and pre-declares what they may do ("room for 2 more"). That is the
34
+ * capacity dial, and it is dead.
35
+ * · a RUNAWAY BOUND is read AT the spawn, by the machine, and produces
36
+ * nothing at all until it actually fires — at which point what surfaces is
37
+ * the machine's own measured sentence AT THE THING THAT IS WAITING ("low
38
+ * memory — 612 MB of 16.0 GB available"), which is the same shape as a CLI
39
+ * relaying that it hit its own limit.
40
+ *
41
+ * So: no number is ever published in advance, nothing is killed (this module
42
+ * signals nothing, ever), nothing is parked (a park needs a human gesture to
43
+ * lift; pressure clears on its own), and a machine that cannot MEASURE refuses
44
+ * nothing — an unreadable /proc is ignorance, and ignorance never withholds.
17
45
  */
18
46
 
47
+ import { execFileSync } from 'node:child_process';
19
48
  import { readFileSync, readdirSync, statfsSync } from 'node:fs';
20
- import { freemem, loadavg } from 'node:os';
49
+ import { freemem, loadavg, platform } from 'node:os';
21
50
  import { MACHINE } from './config.mjs';
22
51
 
52
+ const MiB = 1024 * 1024;
53
+ const GiB = 1024 * MiB;
54
+
23
55
  const readFile = (p) => {
24
56
  try {
25
57
  return readFileSync(p, 'utf8').trim();
@@ -92,6 +124,193 @@ export function processTreeRssBytes(pid) {
92
124
  return total || null;
93
125
  }
94
126
 
127
+ /**
128
+ * HOW MUCH MEMORY A NEW PROCESS COULD ACTUALLY HAVE.
129
+ *
130
+ * Deliberately NOT `freemem()`, which reports pages nobody is using — on any
131
+ * box that has been up a while that is a small number next to a large page
132
+ * cache, so a machine with 12 GB of reclaimable cache reads as nearly full and
133
+ * a guard built on it would refuse everything forever. Linux publishes the
134
+ * honest figure itself (`MemAvailable`, which accounts for what the kernel
135
+ * would reclaim under pressure); macOS does not, so free + inactive + purgeable
136
+ * pages is the nearest thing it will say out loud.
137
+ *
138
+ * The cgroup is asked SECOND and narrows rather than replaces, for the reason
139
+ * `machineLimits` gives in config.mjs: a 4GB container on a 256GB box reads the
140
+ * host through the os module, and oversubscribing there ends with the OOM
141
+ * killer picking a victim by resident size — frequently not the offender.
142
+ *
143
+ * Returns null ONLY where nothing could be read at all, and the caller treats
144
+ * that as ignorance. Everything downstream must keep that distinction: a
145
+ * machine that cannot look must not refuse work.
146
+ *
147
+ * AND FOR ONE RELEASE IT COULD NOT RETURN NULL, which made the sentence above a
148
+ * promise the code did not keep. Every branch ended in `freemem()` — the figure
149
+ * the first paragraph rejects by name — so an unreadable `/proc` (a masked or
150
+ * restricted container), a `MemAvailable:` line this kernel does not publish, or
151
+ * a `vm_stat` that will not run all substituted the ONE number this module says
152
+ * a guard built on it would refuse everything forever. The caller's ignorance
153
+ * arms were dead code, and what shipped instead was a machine deferring every
154
+ * agent turn, Deploy press and wiki sweep indefinitely while relaying "low
155
+ * memory — 612 MB of 16.0 GB available" as if it had measured something. A
156
+ * Flowviant-invented refusal standing on a number this file already calls
157
+ * unusable is worse than no guard.
158
+ *
159
+ * WINDOWS IS THE ONE PLACE `freemem()` IS THE RIGHT FIGURE, and it is kept
160
+ * there deliberately rather than by omission: Node reads it from
161
+ * `GlobalMemoryStatusEx().ullAvailPhys`, which is an AVAILABLE number in the
162
+ * same sense `MemAvailable` is, not a count of untouched pages. Every other
163
+ * platform this does not name reports free pages, so it says nothing at all.
164
+ *
165
+ * `read` is a PARAMETER, and only so the null contract can be pinned: the
166
+ * ignorance case cannot be reached from a test that has a working `/proc`, and
167
+ * an unreachable branch is how the fallback above survived review in the first
168
+ * place. Nothing in the daemon passes it.
169
+ */
170
+ export function memAvailableBytes(read = readFile) {
171
+ if (platform() === 'linux') {
172
+ let avail = null;
173
+ const mi = read('/proc/meminfo');
174
+ const m = mi && mi.match(/^MemAvailable:\s+(\d+)\s+kB/m);
175
+ if (m) avail = Number(m[1]) * 1024;
176
+ const max = read('/sys/fs/cgroup/memory.max');
177
+ if (max && max !== 'max') {
178
+ const limit = Number(max);
179
+ const cur = Number(read('/sys/fs/cgroup/memory.current'));
180
+ if (Number.isFinite(limit) && limit > 0 && Number.isFinite(cur)) {
181
+ const room = Math.max(0, limit - cur);
182
+ avail = avail === null ? room : Math.min(avail, room);
183
+ }
184
+ }
185
+ return avail !== null && Number.isFinite(avail) ? avail : null;
186
+ }
187
+ if (platform() === 'darwin') return darwinAvailableBytes();
188
+ if (platform() === 'win32') {
189
+ const f = freemem();
190
+ return Number.isFinite(f) && f > 0 ? f : null;
191
+ }
192
+ return null;
193
+ }
194
+
195
+ /**
196
+ * `vm_stat`'s page counts, in the units it states in its own header.
197
+ *
198
+ * The page size is READ rather than assumed for exactly the reason
199
+ * `processes.mjs` reads VmRSS's unit instead of multiplying statm by 4096:
200
+ * Apple Silicon runs 16K pages, and a memory readout that is silently four
201
+ * times wrong on somebody's machine is worse than no readout at all.
202
+ */
203
+ function darwinAvailableBytes() {
204
+ let text;
205
+ try {
206
+ text = execFileSync('vm_stat', [], {
207
+ encoding: 'utf8',
208
+ stdio: ['ignore', 'pipe', 'ignore'],
209
+ timeout: 5000,
210
+ });
211
+ } catch {
212
+ return null; // no vm_stat, or it took too long — say nothing
213
+ }
214
+ const pm = text.match(/page size of (\d+) bytes/);
215
+ const page = pm ? Number(pm[1]) : 4096;
216
+ const pagesOf = (label) => {
217
+ const m = text.match(new RegExp(`^${label}:\\s+(\\d+)\\.`, 'm'));
218
+ return m ? Number(m[1]) : 0;
219
+ };
220
+ const pages = pagesOf('Pages free') + pagesOf('Pages inactive') + pagesOf('Pages purgeable');
221
+ return pages > 0 ? pages * page : null;
222
+ }
223
+
224
+ /** The guard off entirely — an escape hatch for a box whose operator knows
225
+ * better than the thresholds. Read per call, not captured at import, so it is
226
+ * true of the environment the daemon is in RIGHT NOW rather than the one it
227
+ * booted in. `absence` and `'0'` both mean the guard is on. */
228
+ export function pressureGuardOff() {
229
+ return process.env.FLOWVIANT_NO_PRESSURE_GUARD === '1';
230
+ }
231
+
232
+ /** An operator's override, or the default. Positive finite numbers only: a
233
+ * typo'd `FLOWVIANT_MIN_FREE_MB=lots` must fall back to the default rather
234
+ * than turning every comparison into NaN, which compares false and would
235
+ * silently disable the guard it was trying to tune. */
236
+ const envNum = (name, fallback) => {
237
+ const n = Number(process.env[name]);
238
+ return Number.isFinite(n) && n > 0 ? n : fallback;
239
+ };
240
+
241
+ const sizeWord = (n) =>
242
+ n >= GiB ? `${(n / GiB).toFixed(1)} GB` : `${Math.round(n / MiB)} MB`;
243
+
244
+ /** How long one measurement stands. The reconcile loop asks several times a
245
+ * tick (one per admission point) and every answer would otherwise be a fresh
246
+ * /proc read — or, on macOS, a fresh `vm_stat` spawn, which is a process per
247
+ * question about whether to start a process. */
248
+ const PRESSURE_CACHE_MS = 2_000;
249
+ let lastMeasure = { at: 0, m: null };
250
+
251
+ /** What the box says about itself, cached briefly. Exported so a caller can
252
+ * hold one reading across several verdicts — and so tests can pass their own
253
+ * instead of depending on the machine they run on. */
254
+ export function measurePressure() {
255
+ const now = Date.now();
256
+ if (lastMeasure.m && now - lastMeasure.at < PRESSURE_CACHE_MS) return lastMeasure.m;
257
+ const m = {
258
+ memAvailable: memAvailableBytes(),
259
+ memTotal: MACHINE.memBytes,
260
+ // Unix only; Windows reports zeroes, which are sent as null rather than as
261
+ // a very calm-looking 0.00 — the same rule the snapshot keeps.
262
+ load1: loadavg()[0] || null,
263
+ cores: MACHINE.cores,
264
+ };
265
+ lastMeasure = { at: now, m };
266
+ return m;
267
+ }
268
+
269
+ /**
270
+ * IS THIS A MOMENT TO START ANOTHER CLI? — null when there is nothing to say.
271
+ *
272
+ * TWO LEVELS, because the two lanes are not the same promise. `churn` is the
273
+ * unattended work — agent turns, a Deploy press's planner, the wiki
274
+ * cartographer — which nobody is sitting in front of and which the server will
275
+ * cheerfully re-offer next poll, so it yields early and generously. A session
276
+ * turn is `interactive`: somebody is watching a composer they just pressed
277
+ * enter in, and deferring that is a visible stall, so it holds out until the
278
+ * box is genuinely about to fall over.
279
+ *
280
+ * THE REASON IS MEASURED WORDS AND NOTHING ELSE. No adjectives about the
281
+ * machine, no advice, no "try again in a few minutes" — a sentence naming the
282
+ * number that fired, which is the only thing this side actually knows.
283
+ *
284
+ * An unreadable measurement contributes NO verdict: memory that could not be
285
+ * read cannot refuse, and a load average this platform does not publish cannot
286
+ * either. Ignorance never withholds.
287
+ */
288
+ export function pressureVerdict(level, measured) {
289
+ if (pressureGuardOff()) return null;
290
+ const m = measured ?? measurePressure();
291
+ const avail = Number.isFinite(m?.memAvailable) ? m.memAvailable : null;
292
+ if (level === 'interactive') {
293
+ const floor = envNum('FLOWVIANT_CRITICAL_FREE_MB', 400) * MiB;
294
+ if (avail !== null && avail < floor)
295
+ return { reason: `nearly out of memory — ${sizeWord(avail)} available` };
296
+ return null;
297
+ }
298
+ const total = Number.isFinite(m?.memTotal) && m.memTotal > 0 ? m.memTotal : 0;
299
+ // A fixed floor AND a proportional one, whichever is larger: 1 GB is the
300
+ // right reserve on a laptop and is nothing on a 256 GB box, where six per
301
+ // cent is the honest "the page cache is already being squeezed" line.
302
+ const floor = Math.max(envNum('FLOWVIANT_MIN_FREE_MB', 1024) * MiB, Math.round(total * 0.06));
303
+ if (avail !== null && avail < floor)
304
+ return { reason: `low memory — ${sizeWord(avail)} of ${sizeWord(total)} available` };
305
+ const cores = Number.isFinite(m?.cores) && m.cores > 0 ? m.cores : null;
306
+ const load = Number.isFinite(m?.load1) ? m.load1 : null;
307
+ if (load !== null && cores !== null && load > cores * envNum('FLOWVIANT_MAX_LOAD_PER_CORE', 4))
308
+ return {
309
+ reason: `cpu overloaded — load ${load.toFixed(1)} on ${cores} core${cores === 1 ? '' : 's'}`,
310
+ };
311
+ return null;
312
+ }
313
+
95
314
  /** Free bytes on the volume holding the worktrees. */
96
315
  export function diskFreeBytes(path) {
97
316
  try {
@@ -120,7 +339,13 @@ export function machineSnapshot({ worktreeDir, tasks = [] } = {}) {
120
339
  diskFree: disk?.free ?? null,
121
340
  diskTotal: disk?.total ?? null,
122
341
  // Per-task, so "the box is full" can be traced to the task that filled it.
342
+ //
343
+ // BOUNDED BEFORE THE WALK, not after: each row costs a /proc tree walk, and
344
+ // the cap exists so a caller that hands over a long list cannot turn a
345
+ // telemetry post into a scan of the whole process table. Sixteen is more
346
+ // live turns than any machine this guard admits will ever hold.
123
347
  tasks: tasks
348
+ .slice(0, 16)
124
349
  .map((t) => ({ taskId: t.intentId, rss: processTreeRssBytes(t.pid) }))
125
350
  .filter((t) => t.taskId && t.rss),
126
351
  };
@@ -201,6 +201,31 @@ export function toolEventOf(name, input = {}, cwd = '', scrub = (s) => s) {
201
201
  }
202
202
  }
203
203
 
204
+ /**
205
+ * THE ACTIVITY KINDS `toolEventOf` ALSO ANSWERS — the dedupe rule, stated once.
206
+ *
207
+ * A Claude tool call goes down BOTH paths in claude.mjs: `humanizeClaudeTool`
208
+ * makes a one-line activity and `toolEventOf` makes a structured event, from
209
+ * the same `tool_use`. A consumer taking both — the agent turn's trace — would
210
+ * otherwise render every read twice, once as a sentence and once as a card.
211
+ *
212
+ * It is the KINDS the structured builder answers, not every tool kind: `LS`
213
+ * produces a `list` activity and no tool event, so dropping `list` would delete
214
+ * it from the trace entirely. And it is only safe to apply on the runtimes
215
+ * whose stream reaches `onToolEvent` at all — codex and agy have their own
216
+ * parsers, never call it, and would go silent. Both halves are pinned in
217
+ * trace.test.mjs, because the two functions are edited independently and the
218
+ * failure is invisible: a dropped prose line looks exactly like a quiet turn.
219
+ */
220
+ export const CLAUDE_TOOL_PROSE_KINDS = new Set([
221
+ 'read',
222
+ 'write',
223
+ 'search',
224
+ 'glob',
225
+ 'bash',
226
+ 'plan',
227
+ ]);
228
+
204
229
  // ── Codex ──────────────────────────────────────────────────────────────────
205
230
 
206
231
  /**
@@ -0,0 +1,194 @@
1
+ /**
2
+ * THE WHOLE TURN, RELAYED — "watch it work".
3
+ *
4
+ * An agent turn already streams everything it does: the CLI prints its
5
+ * thinking, its narration and every tool call, `runTurn` parses all of it, and
6
+ * the agent lane read exactly ONE line out of that stream every two seconds,
7
+ * overwrote the previous one, and threw the rest away. So the board could say
8
+ * an agent was reading a file and never what it had read before that — which is
9
+ * the difference between a spinner with a caption and watching Claude Code.
10
+ *
11
+ * This is the second, DURABLE channel for the same stream. The one-line pulse
12
+ * (`/fleet/agent-activity`) is untouched and still sent: it carries staleness —
13
+ * "the machine last spoke 40 seconds ago" — which an append-only list of steps
14
+ * cannot, because a list that stopped growing and a list that is complete look
15
+ * identical.
16
+ *
17
+ * ── FOUR RULES, AND EACH ONE IS LOAD-BEARING ──
18
+ *
19
+ * ORDER IS THE CONTRACT. Batches are serialized through an await chain, never
20
+ * fired in parallel: the server appends what arrives, so two POSTs in flight at
21
+ * once would interleave a turn's steps into a sequence that never happened.
22
+ *
23
+ * SEQ IS ABSOLUTE, AND DROPS ADVANCE IT. Every entry accepted here gets an
24
+ * index in this turn's whole stream, whether it is sent or shed from a full
25
+ * buffer. That makes a retry idempotent — the server trims what it already has
26
+ * against its high-water mark rather than appending it twice — and it makes a
27
+ * GAP visible: the server's "N earlier steps aren't shown" is computed from the
28
+ * distance between the high-water mark and what it kept, so a daemon-side drop
29
+ * and a server-side prune are reported as the same honest sentence instead of
30
+ * one of them being silent.
31
+ *
32
+ * …AND IT IS ABSOLUTE WITHIN ONE RUN, WHICH IS WHY EVERY BATCH NAMES ITS RUN.
33
+ * The counter lives in this closure, and this closure is built fresh inside
34
+ * `runAgentTurn`; the server's high-water mark lives on the TURN ROW and
35
+ * outlives any number of attempts at it. A turn is re-run whenever the daemon
36
+ * restarts mid-turn, is taken over, or throws after the CLI ran but before the
37
+ * settle — the server hands the identical turn back on the next poll — and the
38
+ * second attempt then opened at `seq: 0` against a mark of three hundred, so
39
+ * every batch it sent was trimmed to nothing and the surface showed the
40
+ * ABANDONED attempt's steps with the new one's tail welded on, no seam, no
41
+ * `dropped` to say so. `run` is a nonce per relay: the server rebases an
42
+ * unfamiliar one onto its current mark, so a second attempt appends AFTER the
43
+ * first instead of being deleted by it, and a retry inside one run still trims
44
+ * exactly as before.
45
+ *
46
+ * SCRUB EVERY STRING. This is the CLI's own stdout — a command echoing an env
47
+ * var, a read of a config file — riding the same uplink the answer does. Prose
48
+ * is scrubbed here; a tool event arrives already scrubbed by `toolEventOf`,
49
+ * which does it over a bounded window BEFORE its own caps for reasons its
50
+ * header states.
51
+ *
52
+ * IT IS A READOUT AND MUST NEVER FAIL A TURN. Every failure is swallowed, the
53
+ * timer is unref'd, and the final flush is bounded — a wedged uplink costs the
54
+ * tail of a trace, never the settle behind it.
55
+ */
56
+
57
+ /** One batch every two seconds — the discipline the tab's narrator keeps, for
58
+ * the same reason: a turn emits hundreds of entries and nobody is reading them
59
+ * faster than that. */
60
+ export const TRACE_FLUSH_MS = 2_000;
61
+ /** Entries per POST. Matches the server's own per-batch cap. */
62
+ export const TRACE_BATCH = 40;
63
+ /** Entries held while the uplink is down. Past this the OLDEST go: a trace is
64
+ * scrollback, and the newest steps are the ones somebody watching wants. The
65
+ * drop is not silent — see the seq rule above. */
66
+ export const TRACE_BUFFER = 120;
67
+ /** Longest prose entry. The server clamps to the same number; doing it here too
68
+ * means a pathological line never becomes the POST. */
69
+ export const TRACE_PROSE_CAP = 300;
70
+
71
+ /**
72
+ * ONE ATTEMPT AT ONE TURN, named.
73
+ *
74
+ * Short on purpose — it is an equality check and nothing else, never an id
75
+ * anybody resolves — and random rather than a counter, because the thing it has
76
+ * to be distinct from is the PREVIOUS PROCESS's attempt at the same turn, which
77
+ * a counter restarting at zero would collide with every time.
78
+ */
79
+ function newRunId() {
80
+ return Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);
81
+ }
82
+
83
+ /**
84
+ * `post(body)` delivers one batch and resolves truthy when the entries may be
85
+ * forgotten — which deliberately includes a permanent refusal (an older server
86
+ * with no such route, a body the server will never accept). Resolving falsy
87
+ * keeps them queued for the next flush.
88
+ */
89
+ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run = newRunId() }) {
90
+ /** Entries not yet delivered. `base` is the ABSOLUTE index of queue[0] in
91
+ * THIS RUN's stream, so `base + queue.length` is everything this relay has
92
+ * ever accepted — sent, queued or shed. Absolute for the TURN is the
93
+ * server's business: it rebases each run onto its own high-water mark. */
94
+ const queue = [];
95
+ let base = 0;
96
+ let dirty = false;
97
+ let timer = null;
98
+ let stopped = false;
99
+ /** The await chain. Every flush appends to it, so batches leave in order
100
+ * however many callers ask at once. */
101
+ let chain = Promise.resolve();
102
+
103
+ const schedule = () => {
104
+ if (timer || stopped || !dirty) return;
105
+ timer = setTimeout(() => {
106
+ timer = null;
107
+ void flush();
108
+ }, TRACE_FLUSH_MS);
109
+ timer.unref?.(); // never hold the process open for a readout
110
+ };
111
+
112
+ const push = (entry) => {
113
+ if (stopped || !entry) return;
114
+ queue.push(entry);
115
+ while (queue.length > TRACE_BUFFER) {
116
+ queue.shift();
117
+ base += 1; // the shed entry keeps its index — the gap is the record
118
+ }
119
+ dirty = true;
120
+ schedule();
121
+ };
122
+
123
+ const drain = async (deadlineMs) => {
124
+ const until = deadlineMs > 0 ? Date.now() + deadlineMs : 0;
125
+ while (queue.length) {
126
+ if (until && Date.now() > until) {
127
+ dirty = true;
128
+ return;
129
+ }
130
+ const entries = queue.slice(0, TRACE_BATCH);
131
+ const seq = base;
132
+ dirty = false;
133
+ let ok = false;
134
+ try {
135
+ ok = (await post({ agentId, turnId, run, seq, entries })) !== false;
136
+ } catch {
137
+ ok = false;
138
+ }
139
+ if (!ok) {
140
+ // Held, at the SAME seq: a retry the server has already seen is
141
+ // trimmed against its high-water mark rather than doubled.
142
+ dirty = true;
143
+ return;
144
+ }
145
+ queue.splice(0, entries.length);
146
+ base += entries.length;
147
+ }
148
+ };
149
+
150
+ const flush = (deadlineMs = 0) => {
151
+ chain = chain.then(() => drain(deadlineMs)).catch(() => {});
152
+ const done = chain;
153
+ void done.then(() => {
154
+ if (dirty) schedule();
155
+ });
156
+ return done;
157
+ };
158
+
159
+ return {
160
+ /** A prose line from the stream. `kind` is the daemon's activity vocabulary
161
+ * (runtimes.mjs); anything that is not thinking or the model speaking is a
162
+ * `note` — the honest bucket for a codex error line or an agy tool name,
163
+ * rather than a wire value invented per runtime. */
164
+ prose(kind, text) {
165
+ const t = scrub(String(text ?? ''))
166
+ .replace(/\s+/g, ' ')
167
+ .trim()
168
+ .slice(0, TRACE_PROSE_CAP);
169
+ if (!t) return;
170
+ push({ k: kind === 'think' ? 'think' : kind === 'say' ? 'say' : 'note', t });
171
+ },
172
+ /** One structured tool event, exactly as `toolEventOf` built it. A tool this
173
+ * builder does not know returns null there and nothing is pushed here — a
174
+ * card is never invented. */
175
+ tool(e) {
176
+ if (e && typeof e === 'object') push({ k: 'tool', e });
177
+ },
178
+ flush,
179
+ /** No more entries, no more timers. The queue survives so a final flush can
180
+ * still deliver it. */
181
+ stop() {
182
+ stopped = true;
183
+ if (timer) {
184
+ clearTimeout(timer);
185
+ timer = null;
186
+ }
187
+ },
188
+ /** For tests and nothing else: what has been accepted, where the next batch
189
+ * would start, and which attempt this relay is. */
190
+ stats() {
191
+ return { run, seq: base, queued: queue.length, emitted: base + queue.length };
192
+ },
193
+ };
194
+ }