@agent-flow-board/bridge 0.1.0 → 0.3.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 (62) hide show
  1. package/README.md +29 -1
  2. package/dist/checks/appdir.js +105 -0
  3. package/dist/checks/approve.js +213 -0
  4. package/dist/checks/audit.js +126 -0
  5. package/dist/checks/bind.js +100 -0
  6. package/dist/checks/board.js +107 -0
  7. package/dist/checks/broker.js +439 -0
  8. package/dist/checks/browser.js +275 -0
  9. package/dist/checks/browserpool.js +221 -0
  10. package/dist/checks/cache.js +710 -0
  11. package/dist/checks/child.js +211 -0
  12. package/dist/checks/command.js +267 -0
  13. package/dist/checks/commands.js +219 -0
  14. package/dist/checks/connect.js +79 -0
  15. package/dist/checks/connections.js +42 -0
  16. package/dist/checks/copy.js +320 -0
  17. package/dist/checks/dbpool.js +710 -0
  18. package/dist/checks/doctor.js +649 -0
  19. package/dist/checks/envPolicy.js +212 -0
  20. package/dist/checks/envrules.js +322 -0
  21. package/dist/checks/evidence.js +114 -0
  22. package/dist/checks/fsretry.js +29 -0
  23. package/dist/checks/guard.js +188 -0
  24. package/dist/checks/guardrails.js +155 -0
  25. package/dist/checks/importlegacy.js +300 -0
  26. package/dist/checks/install.js +150 -0
  27. package/dist/checks/live.js +76 -0
  28. package/dist/checks/lock.js +207 -0
  29. package/dist/checks/login.js +203 -0
  30. package/dist/checks/mailguard-hook.cjs +216 -0
  31. package/dist/checks/mailguard.js +98 -0
  32. package/dist/checks/paths.js +85 -0
  33. package/dist/checks/preview.js +577 -0
  34. package/dist/checks/profiles.js +110 -0
  35. package/dist/checks/provision.js +175 -0
  36. package/dist/checks/recipe.js +246 -0
  37. package/dist/checks/redact.js +52 -0
  38. package/dist/checks/registry.js +548 -0
  39. package/dist/checks/repoenv.js +162 -0
  40. package/dist/checks/roots.js +144 -0
  41. package/dist/checks/routing.js +68 -0
  42. package/dist/checks/run.js +443 -0
  43. package/dist/checks/sandbox.js +208 -0
  44. package/dist/checks/scheduler.js +383 -0
  45. package/dist/checks/scope.js +383 -0
  46. package/dist/checks/sealedNames.js +39 -0
  47. package/dist/checks/secrets.js +452 -0
  48. package/dist/checks/secretscmd.js +80 -0
  49. package/dist/checks/service.js +589 -0
  50. package/dist/checks/signin.js +79 -0
  51. package/dist/checks/singleflight.js +307 -0
  52. package/dist/checks/store.js +92 -0
  53. package/dist/checks/surface.js +124 -0
  54. package/dist/checks/types.js +2 -0
  55. package/dist/checks/warm.js +342 -0
  56. package/dist/checks/watch.js +228 -0
  57. package/dist/checks/wire.js +87 -0
  58. package/dist/cli.js +18 -1
  59. package/dist/hooks/adapters.js +3 -1
  60. package/dist/hooks/call.js +6 -1
  61. package/dist/hooks/modVersion.js +26 -0
  62. package/package.json +2 -2
@@ -0,0 +1,107 @@
1
+ // THE BOARD, SEEN FROM THE CHECK COMMANDS: THE RUNNER API WITH THE TOOL'S OWN RUNNER TOKEN
2
+ // A runner token is not a personal token. It can take a queued run, beat for it, upload evidence and report a result, and nothing else.
3
+ // -- MODULES -- //
4
+ import { TOOL_PROTOCOL } from './paths.js';
5
+ // -- CONSTANTS -- //
6
+ /** One call to the board may take this long, uploads included. A half open connection must not hang the run for ever. */
7
+ export const CALL_TIMEOUT_MS = 120_000;
8
+ /** Asking for a run waits a short while only. */
9
+ export const CLAIM_TIMEOUT_MS = 20_000;
10
+ /** Raised when the board refuses a call. Carries the board's own words and code. */
11
+ export class BoardError extends Error {
12
+ status;
13
+ code;
14
+ constructor(status, code, message) {
15
+ super(message);
16
+ this.status = status;
17
+ this.code = code;
18
+ this.name = 'BoardError';
19
+ }
20
+ }
21
+ // -- FUNCTIONS -- //
22
+ /** The calls of the runner API, as the check commands make them. */
23
+ export class RunnerBoard {
24
+ server;
25
+ token;
26
+ doFetch;
27
+ extraHeaders;
28
+ constructor(server, token, doFetch = (input, init) => fetch(input, init),
29
+ /** Extra headers sent with every call: the audit head and the slots, built by the caller, never required. */
30
+ extraHeaders = () => ({})) {
31
+ this.server = server;
32
+ this.token = token;
33
+ this.doFetch = doFetch;
34
+ this.extraHeaders = extraHeaders;
35
+ }
36
+ async call(method, path, body, type, timeoutMs = CALL_TIMEOUT_MS) {
37
+ let res;
38
+ let text;
39
+ try {
40
+ res = await this.doFetch(`${this.server.replace(/\/$/, '')}/api/v1/runner${path}`, {
41
+ method,
42
+ headers: {
43
+ authorization: `Bearer ${this.token}`,
44
+ 'x-afb-tool-protocol': String(TOOL_PROTOCOL),
45
+ ...this.extraHeaders(),
46
+ ...(type ? { 'content-type': type } : {})
47
+ },
48
+ body: body,
49
+ signal: AbortSignal.timeout(timeoutMs)
50
+ });
51
+ text = await res.text();
52
+ }
53
+ catch (e) {
54
+ const name = e?.name;
55
+ if (name === 'TimeoutError' || name === 'AbortError')
56
+ throw new BoardError(0, 'TIMEOUT', `The board did not answer within ${Math.round(timeoutMs / 1000)} seconds.`);
57
+ throw e;
58
+ }
59
+ let json = null;
60
+ try {
61
+ json = text ? JSON.parse(text) : null;
62
+ }
63
+ catch {
64
+ /* Not JSON */
65
+ }
66
+ if (!res.ok) {
67
+ const err = json?.error;
68
+ if (res.status === 401)
69
+ throw new BoardError(401, 'UNAUTHENTICATED', 'The board no longer accepts this machine for checks. Run: agent-flow-board check connect --server <url> --org <slug> --json');
70
+ throw new BoardError(res.status, err?.code ?? 'ERROR', err?.message ?? `The board answered ${res.status}.`);
71
+ }
72
+ return json;
73
+ }
74
+ /** Takes the run with this id, and no other. Null when it is not queued for this runner. */
75
+ async claim(runId) {
76
+ const out = await this.call('POST', '/claim', JSON.stringify({ runId }), 'application/json', CLAIM_TIMEOUT_MS);
77
+ return out.job;
78
+ }
79
+ /** Takes the oldest queued run this runner may take, for `check watch`. */
80
+ async claimNext() {
81
+ const out = await this.call('POST', '/claim', undefined, undefined, CLAIM_TIMEOUT_MS);
82
+ return out.job;
83
+ }
84
+ /** How many runs wait for this runner. Nothing is taken. */
85
+ async peek() {
86
+ return (await this.call('POST', '/peek')).waiting;
87
+ }
88
+ /** Says the process is alive and still on this run. A conflict means the board gave up on it. */
89
+ beat(runId) {
90
+ return this.call('POST', `/runs/${encodeURIComponent(runId)}/beat`);
91
+ }
92
+ /** Gives back the named runs only, never the runs of another process of the same runner. */
93
+ async release(runIds) {
94
+ if (!runIds.length)
95
+ return 0;
96
+ return (await this.call('POST', '/release', JSON.stringify({ runIds }), 'application/json')).released;
97
+ }
98
+ /** Stores a file of a run as evidence and returns its attachment id. */
99
+ async upload(runId, file) {
100
+ const q = `filename=${encodeURIComponent(file.filename)}&caption=${encodeURIComponent(file.caption)}`;
101
+ const out = await this.call('POST', `/runs/${encodeURIComponent(runId)}/artifacts?${q}`, file.bytes, 'application/octet-stream');
102
+ return out.attachmentId;
103
+ }
104
+ finish(runId, report) {
105
+ return this.call('POST', `/runs/${encodeURIComponent(runId)}/finish`, JSON.stringify(report), 'application/json');
106
+ }
107
+ }
@@ -0,0 +1,439 @@
1
+ // THE MACHINE BROKER: HOW MANY RUNS THIS MACHINE TAKES AT ONCE, ACROSS EVERY `check run` AND `check watch` PROCESS.
2
+ // A slot is a small file in brokerDir() (slots/slot-N.json) holding the process id, the start time and a heartbeat. A slot whose process is gone, or whose heartbeat
3
+ // stopped for the stale window, is reclaimed by the next process that looks. A process that waits for a slot writes a waiter file, so every process sees the same
4
+ // queue: the head of the queue is the waiter whose registration runs the fewest slots now (ties by arrival), and only the head may take a slot. The machine check
5
+ // (free disk, free memory per run that started in the last 90 s) is part of taking a slot and WAITS instead of failing. Nothing here blocks the event loop for
6
+ // longer than a few small file reads.
7
+ // -- DEP-MODULES -- //
8
+ import { randomBytes } from 'node:crypto';
9
+ import { linkSync, readFileSync, readdirSync, renameSync, rmSync, statSync, statfsSync, writeFileSync } from 'node:fs';
10
+ import { cpus, freemem } from 'node:os';
11
+ import { join } from 'node:path';
12
+ // -- MODULES -- //
13
+ import { pidAlive } from './lock.js';
14
+ import { brokerDir, checksHome, ensureDir, poolDir } from './paths.js';
15
+ // -- CONSTANTS -- //
16
+ const GB = 1024 ** 3;
17
+ const MB = 1024 ** 2;
18
+ /** A run that started this recently has not used its memory yet (browser, preview and database are still starting), so it is counted again. */
19
+ export const RECENT_START_MS = 90_000;
20
+ const DEFAULT_STALE_MS = 45_000;
21
+ const DEFAULT_POLL_MS = 250;
22
+ /** A file that cannot be read is only treated as dead after this long (files are created whole, so this means damage). */
23
+ const UNREADABLE_GRACE_MS = 2_000;
24
+ const num = (name, fallback) => {
25
+ const v = Number(process.env[name]);
26
+ return Number.isFinite(v) && v > 0 ? v : fallback;
27
+ };
28
+ // -- FUNCTIONS: SETTINGS -- //
29
+ /** Free disk a new run needs where the tool keeps its runs, in gigabytes. */
30
+ export const minFreeDiskGb = () => num('AFB_CHECKS_MIN_FREE_GB', 4);
31
+ /** Free memory another run needs, in megabytes. */
32
+ export const perRunMemoryMb = () => num('AFB_CHECKS_RUN_MEMORY_MB', 1200);
33
+ /** Slots when nothing is set: as many as the machine has processors, at most 8. The memory and disk preflight still holds a run back when the machine is full. */
34
+ export const defaultSlots = () => Math.max(1, Math.min(cpus().length, 8));
35
+ /** The most runs this machine takes at once: AFB_CHECKS_MAX_PARALLEL, else the configured number, else min(processors, 8); never above the number of processors. */
36
+ export function maxSlots(configured) {
37
+ const raw = process.env.AFB_CHECKS_MAX_PARALLEL ?? configured;
38
+ const wanted = raw === undefined || raw === null || raw === '' ? NaN : Math.floor(Number(raw));
39
+ const cap = Math.max(1, cpus().length);
40
+ return Math.min(cap, Math.max(1, Number.isFinite(wanted) ? wanted : defaultSlots()));
41
+ }
42
+ function freeDisk() {
43
+ try {
44
+ const s = statfsSync(ensureDir(checksHome()));
45
+ return Number(s.bavail) * Number(s.bsize);
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ const realDeps = () => ({
52
+ now: () => Date.now(),
53
+ pidAlive,
54
+ freeDiskBytes: freeDisk,
55
+ freeMemBytes: () => freemem()
56
+ });
57
+ const withDeps = (d) => ({ ...realDeps(), ...d });
58
+ /**
59
+ * Whether another run may start. `busy` is how many runs hold a slot, `recent` how many of them started in the last 90 s. The first run on a machine is never held
60
+ * back for memory (an unreadable or low reading must not stop all work).
61
+ */
62
+ export function preflight(i) {
63
+ const need = minFreeDiskGb();
64
+ if (i.freeDiskBytes !== null && i.freeDiskBytes < need * GB)
65
+ return {
66
+ ok: false,
67
+ code: 'DISK_LOW',
68
+ why: `Only ${(i.freeDiskBytes / GB).toFixed(1)} GB is free where the tool keeps its runs and a run needs ${need} GB. Free some space (agent-flow-board check clear), then it takes runs again.`
69
+ };
70
+ const mem = perRunMemoryMb() * (1 + i.recent);
71
+ if (i.busy > 0 && i.freeMemBytes < mem * MB)
72
+ return {
73
+ ok: false,
74
+ code: 'MEMORY_LOW',
75
+ why: `Only ${Math.round(i.freeMemBytes / MB)} MB of memory is free and another run needs ${mem} MB, so it waits for one of the ${i.busy} running to finish.`
76
+ };
77
+ return { ok: true };
78
+ }
79
+ // -- FUNCTIONS: FILES -- //
80
+ const slotsDir = () => join(brokerDir(), 'slots');
81
+ const waitersDir = () => join(brokerDir(), 'waiters');
82
+ const slotFile = (index) => join(slotsDir(), `slot-${index}.json`);
83
+ function readText(file) {
84
+ try {
85
+ return readFileSync(file, 'utf8');
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ function parse(text) {
92
+ if (text === null)
93
+ return null;
94
+ try {
95
+ const v = JSON.parse(text);
96
+ return v && typeof v === 'object' && v.v === 1 ? v : null;
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
102
+ /** Creates a file with its whole content at once (a temporary file linked into place). False when it exists. */
103
+ function createWhole(file, text) {
104
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
105
+ writeFileSync(tmp, text, { mode: 0o600 });
106
+ try {
107
+ linkSync(tmp, file);
108
+ return true;
109
+ }
110
+ catch (e) {
111
+ if (e.code === 'EEXIST')
112
+ return false;
113
+ throw e;
114
+ }
115
+ finally {
116
+ rmSync(tmp, { force: true });
117
+ }
118
+ }
119
+ /** Replaces a file whole (a temporary file renamed over it). A failure is ignored: the next beat tries again. */
120
+ function replaceWhole(file, text) {
121
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
122
+ try {
123
+ writeFileSync(tmp, text, { mode: 0o600 });
124
+ renameSync(tmp, file);
125
+ }
126
+ catch {
127
+ rmSync(tmp, { force: true });
128
+ }
129
+ }
130
+ /**
131
+ * Removes a dead file the careful way: moved aside under a name of its own, compared with what was seen, and linked back when it turns out to be somebody's fresh
132
+ * file (a dead file that is unlinked and a new one made in between would lose the new one).
133
+ */
134
+ function reap(file, seen, stillDead) {
135
+ const aside = `${file}.aside.${process.pid}.${randomBytes(4).toString('hex')}`;
136
+ try {
137
+ renameSync(file, aside);
138
+ }
139
+ catch {
140
+ return;
141
+ }
142
+ const moved = readText(aside);
143
+ if (moved !== null && moved !== seen && !stillDead(moved)) {
144
+ try {
145
+ linkSync(aside, file);
146
+ }
147
+ catch {
148
+ /* A newer file is already there */
149
+ }
150
+ }
151
+ rmSync(aside, { force: true });
152
+ }
153
+ function listJson(dir) {
154
+ try {
155
+ return readdirSync(dir)
156
+ .filter((f) => f.endsWith('.json'))
157
+ .map((f) => join(dir, f));
158
+ }
159
+ catch {
160
+ return [];
161
+ }
162
+ }
163
+ function unreadableAndOld(file, now) {
164
+ try {
165
+ return now - statSync(file).mtimeMs > UNREADABLE_GRACE_MS;
166
+ }
167
+ catch {
168
+ return false;
169
+ }
170
+ }
171
+ /** Reads the slots and waiters that are alive, and removes the dead ones. */
172
+ function readView(deps, staleMs) {
173
+ const now = deps.now();
174
+ const slots = [];
175
+ for (const file of listJson(slotsDir())) {
176
+ const text = readText(file);
177
+ const body = parse(text);
178
+ const dead = (b, t) => b
179
+ ? !deps.pidAlive(b.pid) || now - b.beat > staleMs
180
+ : t === null
181
+ ? false
182
+ : unreadableAndOld(file, now);
183
+ if (dead(body, text)) {
184
+ reap(file, text, (moved) => dead(parse(moved), moved));
185
+ continue;
186
+ }
187
+ if (body)
188
+ slots.push(body);
189
+ }
190
+ const waiters = [];
191
+ for (const file of listJson(waitersDir())) {
192
+ const text = readText(file);
193
+ const body = parse(text);
194
+ const dead = (b, t) => b
195
+ ? !deps.pidAlive(b.pid) || now - b.beat > staleMs
196
+ : t === null
197
+ ? false
198
+ : unreadableAndOld(file, now);
199
+ if (dead(body, text)) {
200
+ reap(file, text, (moved) => dead(parse(moved), moved));
201
+ continue;
202
+ }
203
+ if (body)
204
+ waiters.push(body);
205
+ }
206
+ return { slots, waiters };
207
+ }
208
+ /** The waiter that goes first: the registration with the fewest running slots, then the earliest arrival. */
209
+ function head(view) {
210
+ const running = new Map();
211
+ for (const s of view.slots)
212
+ running.set(s.registration, (running.get(s.registration) ?? 0) + 1);
213
+ const sorted = [...view.waiters].sort((a, b) => (running.get(a.registration) ?? 0) - (running.get(b.registration) ?? 0) ||
214
+ a.arrivedAt - b.arrivedAt ||
215
+ (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
216
+ return sorted[0] ?? null;
217
+ }
218
+ // -- FUNCTIONS: STATUS -- //
219
+ const inProcessWaiting = new Map();
220
+ /** The runs of this process that wait for a slot right now, so the caller can keep the board told that they are alive. */
221
+ export const waiting = () => [...inProcessWaiting.values()];
222
+ /** How many slots there are and how many are held right now by every process on this machine. */
223
+ export function machineTotals(o = {}) {
224
+ const slots = o.slots ?? maxSlots();
225
+ const view = readView(withDeps(o.deps), o.staleMs ?? DEFAULT_STALE_MS);
226
+ return { slots, busy: Math.min(slots, view.slots.length) };
227
+ }
228
+ /** The value of the x-afb-tool-slots header. */
229
+ export const slotsHeader = (o = {}) => {
230
+ const t = machineTotals(o);
231
+ return `${t.slots}:${t.busy}`;
232
+ };
233
+ /** Whether the machine could take another run now, without waiting: for a scheduler that leaves a run unclaimed when the answer is no. */
234
+ export function canTake(o = {}) {
235
+ const deps = withDeps(o.deps);
236
+ const view = readView(deps, o.staleMs ?? DEFAULT_STALE_MS);
237
+ const now = deps.now();
238
+ return preflight({
239
+ busy: view.slots.length,
240
+ recent: view.slots.filter((s) => now - s.startedAt < RECENT_START_MS).length,
241
+ freeDiskBytes: deps.freeDiskBytes(),
242
+ freeMemBytes: deps.freeMemBytes()
243
+ });
244
+ }
245
+ /** The pools (browser, database) other processes of this machine left a file for in poolDir(). The warm folder is the warm store's. */
246
+ export function listPools() {
247
+ const out = [];
248
+ for (const file of listJson(poolDir())) {
249
+ const text = readText(file);
250
+ let info = null;
251
+ try {
252
+ info = text === null ? null : JSON.parse(text);
253
+ }
254
+ catch {
255
+ /* A damaged file is listed without its content */
256
+ }
257
+ out.push({ name: file.slice(poolDir().length + 1).replace(/\.json$/, ''), file, info });
258
+ }
259
+ return out;
260
+ }
261
+ // -- FUNCTIONS: ACQUIRE -- //
262
+ const held = new Set();
263
+ let exitHook = false;
264
+ function dropHeldOnExit() {
265
+ if (exitHook)
266
+ return;
267
+ exitHook = true;
268
+ process.once('exit', () => {
269
+ for (const h of held) {
270
+ const body = parse(readText(h.file));
271
+ if (body?.token === h.token)
272
+ rmSync(h.file, { force: true });
273
+ }
274
+ });
275
+ }
276
+ const sleep = (ms, signal) => new Promise((resolve) => {
277
+ const t = setTimeout(done, ms);
278
+ function done() {
279
+ clearTimeout(t);
280
+ signal?.removeEventListener('abort', done);
281
+ resolve();
282
+ }
283
+ signal?.addEventListener('abort', done, { once: true });
284
+ });
285
+ export const abortError = () => Object.assign(new Error('Waiting for a slot was stopped.'), { name: 'AbortError' });
286
+ function tryTake(req, slots, deps, staleMs) {
287
+ ensureDir(slotsDir());
288
+ for (let index = 0; index < slots; index++) {
289
+ const file = slotFile(index);
290
+ const token = randomBytes(8).toString('hex');
291
+ const now = deps.now();
292
+ const body = {
293
+ v: 1,
294
+ index,
295
+ pid: process.pid,
296
+ token,
297
+ runId: req.runId,
298
+ kind: req.kind,
299
+ registration: req.registration,
300
+ startedAt: now,
301
+ beat: now
302
+ };
303
+ if (!createWhole(file, JSON.stringify(body) + '\n'))
304
+ continue;
305
+ // Slots with a number above the limit (the limit was lowered) still count: when the machine is over the limit now, this one goes back
306
+ if (readView(deps, staleMs).slots.length > slots) {
307
+ rmSync(file, { force: true });
308
+ return null;
309
+ }
310
+ return makeSlot(file, body, deps, staleMs);
311
+ }
312
+ return null;
313
+ }
314
+ function makeSlot(file, body, deps, staleMs) {
315
+ const handle = { file, token: body.token };
316
+ held.add(handle);
317
+ dropHeldOnExit();
318
+ let released = false;
319
+ let lost = false;
320
+ const beat = () => {
321
+ if (released || lost)
322
+ return;
323
+ const current = parse(readText(file));
324
+ if (!current || current.token !== body.token) {
325
+ lost = true;
326
+ return;
327
+ }
328
+ replaceWhole(file, JSON.stringify({ ...current, beat: deps.now() }) + '\n');
329
+ };
330
+ const timer = setInterval(beat, Math.max(20, Math.floor(staleMs / 3)));
331
+ timer.unref();
332
+ return {
333
+ id: body.token,
334
+ index: body.index,
335
+ held: () => !released && !lost,
336
+ release() {
337
+ if (released)
338
+ return;
339
+ released = true;
340
+ clearInterval(timer);
341
+ held.delete(handle);
342
+ const text = readText(file);
343
+ if (parse(text)?.token === body.token)
344
+ reap(file, text, () => true);
345
+ }
346
+ };
347
+ }
348
+ /**
349
+ * Waits for a slot and returns it. Waits (never fails) while the machine has no free slot, while other registrations go first, and while the disk or memory check
350
+ * says no. Rejects with an AbortError when the signal aborts. The caller releases the slot in a finally. The waiter file is beaten while waiting, and `waiting()`
351
+ * lists what this process waits for, so the caller keeps the board told that the run is alive.
352
+ */
353
+ export async function acquireSlot(req, opts = {}) {
354
+ const deps = withDeps(opts.deps);
355
+ const slots = opts.slots ?? maxSlots();
356
+ const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
357
+ const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
358
+ const { signal } = opts;
359
+ ensureDir(waitersDir());
360
+ const id = `${req.runId}-${randomBytes(3).toString('hex')}`;
361
+ const file = join(waitersDir(), `${id}.json`);
362
+ const arrivedAt = deps.now();
363
+ let lastBeat = 0;
364
+ const writeWaiter = (force = false) => {
365
+ const now = deps.now();
366
+ if (!force && now - lastBeat < staleMs / 3)
367
+ return;
368
+ lastBeat = now;
369
+ const body = {
370
+ v: 1,
371
+ id,
372
+ pid: process.pid,
373
+ runId: req.runId,
374
+ registration: req.registration,
375
+ arrivedAt,
376
+ beat: now
377
+ };
378
+ replaceWhole(file, JSON.stringify(body) + '\n');
379
+ };
380
+ writeWaiter(true);
381
+ const since = arrivedAt;
382
+ let said = null;
383
+ try {
384
+ for (;;) {
385
+ if (signal?.aborted)
386
+ throw abortError();
387
+ writeWaiter();
388
+ const view = readView(deps, staleMs);
389
+ let reason = null;
390
+ let why = '';
391
+ if (view.slots.length >= slots) {
392
+ reason = 'slots';
393
+ why = `All ${slots} slot${slots === 1 ? ' is' : 's are'} busy, so this run waits for one.`;
394
+ }
395
+ else if (head(view)?.id !== id) {
396
+ reason = 'turn';
397
+ why =
398
+ 'Another run goes first (the registrations take turns), so this run waits for its turn.';
399
+ }
400
+ else {
401
+ const now = deps.now();
402
+ const pf = preflight({
403
+ busy: view.slots.length,
404
+ recent: view.slots.filter((s) => now - s.startedAt < RECENT_START_MS).length,
405
+ freeDiskBytes: deps.freeDiskBytes(),
406
+ freeMemBytes: deps.freeMemBytes()
407
+ });
408
+ if (!pf.ok) {
409
+ reason = pf.code;
410
+ why = pf.why;
411
+ }
412
+ }
413
+ if (!reason) {
414
+ const slot = tryTake(req, slots, deps, staleMs);
415
+ if (slot)
416
+ return slot;
417
+ reason = 'slots';
418
+ why = 'Another process took the slot first, so this run waits for the next one.';
419
+ }
420
+ const info = {
421
+ runId: req.runId,
422
+ registration: req.registration,
423
+ since,
424
+ reason,
425
+ why
426
+ };
427
+ inProcessWaiting.set(id, info);
428
+ if (said !== reason) {
429
+ said = reason;
430
+ opts.onWait?.(info);
431
+ }
432
+ await sleep(pollMs, signal);
433
+ }
434
+ }
435
+ finally {
436
+ inProcessWaiting.delete(id);
437
+ rmSync(file, { force: true });
438
+ }
439
+ }