flowviant 0.82.0 → 0.85.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.
- package/README.md +13 -0
- package/bin/lib/admission.mjs +116 -0
- package/bin/lib/config.mjs +44 -4
- package/bin/lib/env.mjs +24 -3
- package/bin/lib/fleet.mjs +393 -9
- package/bin/lib/instance.mjs +7 -0
- package/bin/lib/resources.mjs +226 -1
- package/bin/lib/runtimes.mjs +25 -0
- package/bin/lib/trace.mjs +194 -0
- package/bin/lib/work.mjs +457 -9
- package/package.json +1 -1
package/bin/lib/resources.mjs
CHANGED
|
@@ -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
|
};
|
package/bin/lib/runtimes.mjs
CHANGED
|
@@ -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
|
+
}
|