harness-dispatch 0.9.0 → 0.10.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 (125) hide show
  1. package/CHANGELOG.md +560 -1
  2. package/README.md +74 -43
  3. package/dist/auth.d.ts.map +1 -1
  4. package/dist/auth.js.map +1 -1
  5. package/dist/bin.d.ts.map +1 -1
  6. package/dist/bin.js +166 -29
  7. package/dist/bin.js.map +1 -1
  8. package/dist/config/coercions.d.ts +5 -0
  9. package/dist/config/coercions.d.ts.map +1 -1
  10. package/dist/config/coercions.js +8 -0
  11. package/dist/config/coercions.js.map +1 -1
  12. package/dist/config/protocol.js.map +1 -1
  13. package/dist/config/validation.d.ts.map +1 -1
  14. package/dist/config/validation.js.map +1 -1
  15. package/dist/config.d.ts +11 -3
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +111 -148
  18. package/dist/config.js.map +1 -1
  19. package/dist/configure-yaml.d.ts.map +1 -1
  20. package/dist/configure-yaml.js +57 -13
  21. package/dist/configure-yaml.js.map +1 -1
  22. package/dist/dispatch-log.d.ts.map +1 -1
  23. package/dist/dispatch-log.js +3 -1
  24. package/dist/dispatch-log.js.map +1 -1
  25. package/dist/dispatchers/generic-cli.d.ts.map +1 -1
  26. package/dist/dispatchers/generic-cli.js +189 -97
  27. package/dist/dispatchers/generic-cli.js.map +1 -1
  28. package/dist/dispatchers/openai-compatible.d.ts +0 -5
  29. package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
  30. package/dist/dispatchers/openai-compatible.js +129 -82
  31. package/dist/dispatchers/openai-compatible.js.map +1 -1
  32. package/dist/dispatchers/shared/harness-login.d.ts.map +1 -1
  33. package/dist/dispatchers/shared/harness-login.js +7 -2
  34. package/dist/dispatchers/shared/harness-login.js.map +1 -1
  35. package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
  36. package/dist/dispatchers/shared/stream-subprocess.js +7 -60
  37. package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
  38. package/dist/file-lock.d.ts.map +1 -1
  39. package/dist/file-lock.js +31 -19
  40. package/dist/file-lock.js.map +1 -1
  41. package/dist/harness-presets.d.ts +51 -0
  42. package/dist/harness-presets.d.ts.map +1 -0
  43. package/dist/harness-presets.js +154 -0
  44. package/dist/harness-presets.js.map +1 -0
  45. package/dist/http/parse.d.ts +8 -1
  46. package/dist/http/parse.d.ts.map +1 -1
  47. package/dist/http/parse.js +10 -3
  48. package/dist/http/parse.js.map +1 -1
  49. package/dist/http/server.d.ts.map +1 -1
  50. package/dist/http/server.js +9 -2
  51. package/dist/http/server.js.map +1 -1
  52. package/dist/job-runner.js +6 -0
  53. package/dist/job-runner.js.map +1 -1
  54. package/dist/jobs/lifecycle.d.ts +100 -0
  55. package/dist/jobs/lifecycle.d.ts.map +1 -0
  56. package/dist/jobs/lifecycle.js +263 -0
  57. package/dist/jobs/lifecycle.js.map +1 -0
  58. package/dist/jobs/read.d.ts +17 -0
  59. package/dist/jobs/read.d.ts.map +1 -0
  60. package/dist/jobs/read.js +103 -0
  61. package/dist/jobs/read.js.map +1 -0
  62. package/dist/jobs/run.d.ts +45 -0
  63. package/dist/jobs/run.d.ts.map +1 -0
  64. package/dist/jobs/run.js +351 -0
  65. package/dist/jobs/run.js.map +1 -0
  66. package/dist/jobs/start.d.ts +9 -0
  67. package/dist/jobs/start.d.ts.map +1 -0
  68. package/dist/jobs/start.js +126 -0
  69. package/dist/jobs/start.js.map +1 -0
  70. package/dist/jobs/store.d.ts.map +1 -1
  71. package/dist/jobs/store.js +13 -3
  72. package/dist/jobs/store.js.map +1 -1
  73. package/dist/jobs/supervisor.d.ts +151 -0
  74. package/dist/jobs/supervisor.d.ts.map +1 -0
  75. package/dist/jobs/supervisor.js +679 -0
  76. package/dist/jobs/supervisor.js.map +1 -0
  77. package/dist/jobs.d.ts +15 -233
  78. package/dist/jobs.d.ts.map +1 -1
  79. package/dist/jobs.js +14 -1409
  80. package/dist/jobs.js.map +1 -1
  81. package/dist/leaderboard.d.ts.map +1 -1
  82. package/dist/leaderboard.js +5 -1
  83. package/dist/leaderboard.js.map +1 -1
  84. package/dist/mcp/config-hot-reload.d.ts.map +1 -1
  85. package/dist/mcp/config-hot-reload.js +4 -1
  86. package/dist/mcp/config-hot-reload.js.map +1 -1
  87. package/dist/mcp/dispatcher-factory.js +1 -1
  88. package/dist/mcp/dispatcher-factory.js.map +1 -1
  89. package/dist/mcp/resources.d.ts.map +1 -1
  90. package/dist/mcp/resources.js +3 -2
  91. package/dist/mcp/resources.js.map +1 -1
  92. package/dist/mcp/tools.d.ts +9 -2
  93. package/dist/mcp/tools.d.ts.map +1 -1
  94. package/dist/mcp/tools.js +45 -6
  95. package/dist/mcp/tools.js.map +1 -1
  96. package/dist/observability/spans.d.ts.map +1 -1
  97. package/dist/observability/spans.js +10 -2
  98. package/dist/observability/spans.js.map +1 -1
  99. package/dist/redaction.d.ts +90 -0
  100. package/dist/redaction.d.ts.map +1 -0
  101. package/dist/redaction.js +221 -0
  102. package/dist/redaction.js.map +1 -0
  103. package/dist/router.d.ts +12 -23
  104. package/dist/router.d.ts.map +1 -1
  105. package/dist/router.js +83 -224
  106. package/dist/router.js.map +1 -1
  107. package/dist/state-dir.d.ts.map +1 -1
  108. package/dist/state-dir.js +15 -1
  109. package/dist/state-dir.js.map +1 -1
  110. package/dist/status.d.ts +14 -3
  111. package/dist/status.d.ts.map +1 -1
  112. package/dist/status.js +36 -3
  113. package/dist/status.js.map +1 -1
  114. package/dist/workspace-lock.d.ts +20 -0
  115. package/dist/workspace-lock.d.ts.map +1 -1
  116. package/dist/workspace-lock.js +63 -0
  117. package/dist/workspace-lock.js.map +1 -1
  118. package/dist/workspace-resolve.d.ts +0 -9
  119. package/dist/workspace-resolve.d.ts.map +1 -1
  120. package/dist/workspace-resolve.js +38 -183
  121. package/dist/workspace-resolve.js.map +1 -1
  122. package/dist/workspaces.d.ts.map +1 -1
  123. package/dist/workspaces.js +63 -139
  124. package/dist/workspaces.js.map +1 -1
  125. package/package.json +5 -4
@@ -0,0 +1,679 @@
1
+ /**
2
+ * Admission control and the supervisor pool: who runs, when, and in which
3
+ * process.
4
+ *
5
+ * The concurrency cap here exists because of a measured OOM, and the pool
6
+ * exists because a runner process per job costs ~76 MB of wrapper. Both are
7
+ * load-bearing; see the comments on DEFAULT_MAX_CONCURRENT_RUNS.
8
+ */
9
+ import { spawn } from "node:child_process";
10
+ import { executeJobDir, resolveRunnerPath } from "./run.js";
11
+ import { listAsyncJobs } from "./read.js";
12
+ import { randomUUID } from "node:crypto";
13
+ import { closeSync, existsSync, mkdirSync, openSync, writeFileSync } from "node:fs";
14
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
15
+ import path from "node:path";
16
+ import { loadConfig } from "../config.js";
17
+ import { ConfigHotReloader } from "../mcp/config-hot-reload.js";
18
+ import { acquireWorkspaceLock } from "../workspace-lock.js";
19
+ import { cancelRequested, jobsRoot, ORPHAN_THRESHOLD_MS, pollInstructions, readJson, timestamp, updateStatus, } from "./store.js";
20
+ /**
21
+ * Default ceiling on agent CLIs running at once, machine-wide.
22
+ *
23
+ * 4 is a resource guard, not a throughput target. Measured 2026-08-03: 20
24
+ * dispatches to one route, 13 running concurrently, 10 of the 20 failing, one
25
+ * killed outright by a Rust OOM inside Codex. Agent CLIs each carry a model
26
+ * runtime; the binding constraint is memory, not cores, so this does NOT
27
+ * scale with CPU count. Override with `max_concurrent_runs:` in config.yaml.
28
+ *
29
+ * `0` lifts the cap without leaving the pool. It used to do more than lift it:
30
+ * it took the slot queue and the supervisor pool out of the path entirely, so
31
+ * every job got its own detached runner — measured under load at 8 concurrent
32
+ * dispatches becoming 8 runner processes at ~76 MB each, which is the per-job
33
+ * wrapper cost the pool was introduced to remove. On the memory-bound machine
34
+ * this cap exists for, the setting that reads like "no limit" was the one that
35
+ * cost the most memory.
36
+ *
37
+ * The uncapped case now sizes the pool by outstanding work instead of by
38
+ * dividing the limit (dividing an infinite one provisions zero supervisors,
39
+ * which is what made this look hard). Jobs are unbounded; runner processes are
40
+ * not.
41
+ */
42
+ const DEFAULT_MAX_CONCURRENT_RUNS = 4;
43
+ /** A CLI harness is a whole agent process; an endpoint call is one HTTP request. */
44
+ const DEFAULT_CLI_WEIGHT = 1.0;
45
+ const DEFAULT_ENDPOINT_WEIGHT = 0.1;
46
+ /**
47
+ * The cap, or `null` for "no cap".
48
+ *
49
+ * `null` rather than `0`, and rather than `Infinity`, because both of those
50
+ * were wrong in a way that mattered. `0` used to short-circuit the whole slot
51
+ * queue and supervisor pool, so `max_concurrent_runs: 0` — documented as
52
+ * lifting a limit — silently gave every job its own runner process at ~76 MB,
53
+ * which is the per-job cost the pool exists to remove, on the memory-bound
54
+ * machine the cap exists for. And `Infinity` divides badly: the pool sizes
55
+ * itself with `outstanding / jobsPerSupervisor(limit)`, so an infinite limit
56
+ * asked for ZERO supervisors. An explicit `null` makes each site say what it
57
+ * means about the unbounded case.
58
+ */
59
+ export function maxConcurrentRuns(config) {
60
+ const configured = config?.maxConcurrentRuns;
61
+ if (configured !== undefined && Number.isFinite(configured) && configured >= 0) {
62
+ return configured === 0 ? null : configured;
63
+ }
64
+ return DEFAULT_MAX_CONCURRENT_RUNS;
65
+ }
66
+ /** Job dirs, oldest first by name — jobIds embed Date.now(), so name order is start order. */
67
+ async function readJobStatuses() {
68
+ const root = jobsRoot();
69
+ if (!existsSync(root))
70
+ return [];
71
+ const entries = await readdir(root, { withFileTypes: true });
72
+ const out = [];
73
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
74
+ if (!entry.isDirectory())
75
+ continue;
76
+ const jobDir = path.join(root, entry.name);
77
+ try {
78
+ out.push({ jobDir, status: await readJson(path.join(jobDir, "status.json")) });
79
+ }
80
+ catch {
81
+ // Half-written or pruned mid-scan — not a live run either way.
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+ /**
87
+ * Occupied slots: jobs actually executing right now. Counts `running` (and
88
+ * plain `queued` — a runner spawned but not yet started) only while the
89
+ * heartbeat is fresh, so a crashed runner's slot is reclaimed by the same
90
+ * ORPHAN_THRESHOLD_MS rule that already frees its status. Slot-queued jobs
91
+ * are waiting for a slot, not holding one.
92
+ */
93
+ /**
94
+ * What one run of a route costs against the concurrency budget.
95
+ *
96
+ * Unknown routes count as a full 1.0 on purpose. A job that has not been
97
+ * routed yet (no forced `service`) has no weight to look up, and this bound
98
+ * exists because a measured burst of 13 concurrent CLIs exhausted memory —
99
+ * so the safe assumption for "might be anything" is "might be heavy".
100
+ */
101
+ export function resourceWeightFor(status, config) {
102
+ const routeId = status.route ?? status.service;
103
+ const svc = routeId !== undefined ? config?.services?.[routeId] : undefined;
104
+ if (svc?.resourceWeight !== undefined && Number.isFinite(svc.resourceWeight) && svc.resourceWeight >= 0) {
105
+ return svc.resourceWeight;
106
+ }
107
+ if (svc?.type === "openai_compatible")
108
+ return DEFAULT_ENDPOINT_WEIGHT;
109
+ return DEFAULT_CLI_WEIGHT;
110
+ }
111
+ /** In-flight jobs, counted. Used for supervisor pool sizing, not for the budget. */
112
+ function countActiveJobs(statuses) {
113
+ let n = 0;
114
+ for (const { status } of statuses) {
115
+ if (status.slotQueued)
116
+ continue;
117
+ if (status.status !== "running" && status.status !== "queued")
118
+ continue;
119
+ const beat = Date.parse(status.updatedAt);
120
+ if (Number.isFinite(beat) && Date.now() - beat > ORPHAN_THRESHOLD_MS)
121
+ continue;
122
+ n += 1;
123
+ }
124
+ return n;
125
+ }
126
+ /**
127
+ * Capacity currently in use, as a weighted sum rather than a job count.
128
+ *
129
+ * With every weight at 1.0 this is exactly the old count, so an existing
130
+ * `max_concurrent_runs` keeps its previous meaning.
131
+ */
132
+ export function activeCapacity(statuses, config) {
133
+ let active = 0;
134
+ for (const { status } of statuses) {
135
+ if (status.slotQueued)
136
+ continue;
137
+ if (status.status !== "running" && status.status !== "queued")
138
+ continue;
139
+ const beat = Date.parse(status.updatedAt);
140
+ if (Number.isFinite(beat) && Date.now() - beat > ORPHAN_THRESHOLD_MS)
141
+ continue;
142
+ active += resourceWeightFor(status, config);
143
+ }
144
+ return active;
145
+ }
146
+ // ---------------------------------------------------------------------------
147
+ // Supervisor pool
148
+ // ---------------------------------------------------------------------------
149
+ /**
150
+ * How many supervisor PROCESSES may exist, regardless of how many jobs run.
151
+ *
152
+ * Previously every job got its own detached Node process. Measured on Windows
153
+ * with Node 24: a bare node process is 52 MB RSS and one that has bootstrapped
154
+ * a runtime is 65 MB, against ~54 MB for the agent CLI it exists to supervise.
155
+ * So more than half the memory of a concurrent run was wrapper, and it scaled
156
+ * linearly — 13 concurrent jobs meant 845 MB of supervision before any agent
157
+ * had read a file. That is the concurrency ceiling.
158
+ *
159
+ * A supervisor is almost entirely idle: it waits on a child process and writes
160
+ * the result. One can watch several at once for the cost of async I/O, so
161
+ * wrapper memory becomes O(1) in the number of jobs instead of O(N), capped
162
+ * here at ~260 MB.
163
+ *
164
+ * Four rather than one purely to bound blast radius: a supervisor crash strands
165
+ * only the jobs it held. Those are recoverable anyway — the job directory is
166
+ * the source of truth and the heartbeat check already marks stranded jobs
167
+ * orphaned — but losing a quarter of in-flight work beats losing all of it.
168
+ */
169
+ export const SUPERVISOR_POOL_SIZE = 4;
170
+ /** Poll interval while a supervisor waits for claimable work. */
171
+ const SUPERVISOR_POLL_MS = 250;
172
+ /** How long a supervisor stays alive with nothing to do before exiting. */
173
+ const SUPERVISOR_IDLE_EXIT_MS = 5_000;
174
+ /**
175
+ * Jobs one supervisor may run at once, so the pool can reach the global limit.
176
+ *
177
+ * Uncapped, a supervisor takes whatever it can claim: the pool size is then
178
+ * the only bound, which is the point — processes stay bounded even when jobs
179
+ * do not.
180
+ */
181
+ function jobsPerSupervisor(limit) {
182
+ if (limit === null)
183
+ return Number.POSITIVE_INFINITY;
184
+ return Math.max(1, Math.ceil(limit / SUPERVISOR_POOL_SIZE));
185
+ }
186
+ /**
187
+ * Take exclusive ownership of a job directory.
188
+ *
189
+ * `wx` fails if the file exists, atomically, on both Windows and POSIX — which
190
+ * is what stops two supervisors racing onto the same job. A claim left behind
191
+ * by a crashed supervisor is reclaimed once that job's heartbeat has gone
192
+ * stale, by the same ORPHAN_THRESHOLD_MS rule used everywhere else.
193
+ *
194
+ * Exported for tests: the one-winner property under concurrent reclaim is the
195
+ * invariant, and it is only checkable by calling this directly.
196
+ */
197
+ export async function claimJobDir(jobDir, status) {
198
+ const claimPath = path.join(jobDir, "claim.json");
199
+ try {
200
+ await writeFile(claimPath, JSON.stringify({ pid: process.pid, at: timestamp() }), {
201
+ encoding: "utf8",
202
+ mode: 0o600,
203
+ flag: "wx",
204
+ });
205
+ return true;
206
+ }
207
+ catch {
208
+ const beat = Date.parse(status.updatedAt);
209
+ if (!Number.isFinite(beat) || Date.now() - beat <= ORPHAN_THRESHOLD_MS)
210
+ return false;
211
+ // Reclaiming a crashed supervisor's claim must pick exactly ONE winner.
212
+ // This path used to rewrite claim.json WITHOUT `wx`, so two supervisors
213
+ // deciding "stale" in the same window both succeeded — the job ran twice,
214
+ // a duplicate CLI execution billed twice. Renaming the stale claim aside
215
+ // is atomic: the loser gets ENOENT and leaves the job alone, and the
216
+ // winner still has to win the `wx` create below like any first claimant.
217
+ const tomb = path.join(path.dirname(claimPath), `claim.stale-${process.pid}-${Date.now().toString(36)}`);
218
+ try {
219
+ await rename(claimPath, tomb);
220
+ }
221
+ catch {
222
+ return false; // Another supervisor reclaimed it first.
223
+ }
224
+ await rm(tomb, { force: true }).catch(() => undefined);
225
+ try {
226
+ await writeFile(claimPath, JSON.stringify({ pid: process.pid, at: timestamp() }), {
227
+ encoding: "utf8",
228
+ mode: 0o600,
229
+ flag: "wx",
230
+ });
231
+ return true;
232
+ }
233
+ catch {
234
+ return false;
235
+ }
236
+ }
237
+ }
238
+ /**
239
+ * Oldest released-but-unstarted job this supervisor can take, or undefined.
240
+ *
241
+ * "Released" means drainSlotQueue already granted it a slot and cleared
242
+ * slotQueued; it is waiting for a supervisor rather than for capacity. A job
243
+ * that is still slotQueued is deliberately NOT claimable here — that would let
244
+ * a supervisor jump the FIFO order the drainer exists to enforce.
245
+ */
246
+ async function claimNextJob() {
247
+ const statuses = await readJobStatuses();
248
+ for (const { jobDir, status } of statuses) {
249
+ if (status.slotQueued)
250
+ continue;
251
+ if (status.status !== "queued")
252
+ continue;
253
+ // Cancelled before a supervisor ever picked it up: claiming it would
254
+ // start work someone has already asked not to happen.
255
+ if (cancelRequested(jobDir))
256
+ continue;
257
+ if (!(await claimJobDir(jobDir, status)))
258
+ continue;
259
+ return jobDir;
260
+ }
261
+ return undefined;
262
+ }
263
+ /**
264
+ * Supervisor main loop: claim work, run several jobs at once, exit when idle.
265
+ *
266
+ * Exiting on idle keeps the no-jobs steady state at zero processes, same as
267
+ * before — the pool is a way to share supervision cost while work exists, not
268
+ * a daemon.
269
+ */
270
+ export async function runSupervisor(deps, supervisorId) {
271
+ const inflight = new Set();
272
+ let idleSince = Date.now();
273
+ // Static now. This was a runtime import to dodge a cycle: config-hot-reload
274
+ // imported setJobRetentionDays from THIS file rather than from
275
+ // jobs/store.ts, where it is defined. Pointing that import at the
276
+ // definition removed the cycle, so the workaround went with it.
277
+ const reloader = new ConfigHotReloader(deps.holder, deps.holder.state.configPath);
278
+ // Heartbeat so drainSlotQueue can tell how many supervisors already exist
279
+ // and avoid piling on. Same staleness rule as jobs, so a killed supervisor
280
+ // stops being counted without anything having to clean up after it.
281
+ const beatDir = path.join(jobsRoot(), ".supervisors");
282
+ // Adopt the file the spawning process already created for this slot, so the
283
+ // slot is continuously accounted for rather than briefly disappearing
284
+ // between the parent's registration and the child's first beat.
285
+ const beatFile = path.join(beatDir, `${supervisorId ?? process.pid}.txt`);
286
+ await mkdir(beatDir, { recursive: true, mode: 0o700 });
287
+ const beat = async () => {
288
+ try {
289
+ await writeFile(beatFile, timestamp(), { encoding: "utf8", mode: 0o600 });
290
+ }
291
+ catch {
292
+ // A missing heartbeat only risks an extra supervisor, which exits idle.
293
+ }
294
+ };
295
+ await beat();
296
+ const beatTimer = setInterval(() => void beat(), SUPERVISOR_POLL_MS * 4);
297
+ const cleanup = async () => {
298
+ clearInterval(beatTimer);
299
+ try {
300
+ await rm(beatFile, { force: true });
301
+ }
302
+ catch {
303
+ // Stale file ages out of the liveness count on its own.
304
+ }
305
+ };
306
+ try {
307
+ for (;;) {
308
+ const limit = maxConcurrentRuns(deps.holder.state.config);
309
+ // Exit at once if the jobs root has gone. A per-job runner died with its
310
+ // job, so a deleted jobs directory could never strand one; a pooled
311
+ // supervisor outlives individual jobs and would otherwise sit polling a
312
+ // path that no longer exists — spinning in the field, and in tests
313
+ // interfering with whatever creates the next jobs root.
314
+ if (!existsSync(jobsRoot()))
315
+ return;
316
+ if (inflight.size < jobsPerSupervisor(limit)) {
317
+ // Pick up config edits before claiming anything.
318
+ //
319
+ // A supervisor OUTLIVES the server that spawned it, by design and by
320
+ // up to SUPERVISOR_IDLE_EXIT_MS. Without this it also outlived the
321
+ // server's CONFIG: restart with a route removed and dispatch inside
322
+ // that window, and the old supervisor claimed the job and ran the
323
+ // removed route, reporting plain success. `disabled:`,
324
+ // `allow_paid_usage` and safety profiles are meant to be controls, and
325
+ // for those few seconds they were not — against this product's own
326
+ // "never spend money silently".
327
+ //
328
+ // maybeReload is mtime-gated, so the steady-state cost is one stat per
329
+ // poll, and it keeps the old state when an edit is malformed.
330
+ await reloader.maybeReload();
331
+ // Promote waiting jobs into released ones first. The old per-job
332
+ // runner called drainSlotQueue as it exited, which is what kept the
333
+ // queue moving; a pooled supervisor outlives individual jobs, so it
334
+ // has to do the same thing on every pass or a slot freed by a job it
335
+ // just finished never reaches the next job in line.
336
+ try {
337
+ await drainSlotQueue(deps.holder.state.config, deps.holder.state.configPath);
338
+ }
339
+ catch {
340
+ // Next pass retries; a drain failure must not kill the supervisor.
341
+ }
342
+ const jobDir = await claimNextJob();
343
+ if (jobDir !== undefined) {
344
+ idleSince = Date.now();
345
+ const run = executeJobDir(deps, jobDir)
346
+ .catch(() => undefined)
347
+ .finally(() => inflight.delete(run));
348
+ inflight.add(run);
349
+ continue; // Try to fill the remaining slots before waiting.
350
+ }
351
+ }
352
+ if (inflight.size === 0) {
353
+ if (Date.now() - idleSince > SUPERVISOR_IDLE_EXIT_MS)
354
+ return;
355
+ await new Promise((r) => setTimeout(r, SUPERVISOR_POLL_MS));
356
+ continue;
357
+ }
358
+ idleSince = Date.now();
359
+ await Promise.race([...inflight, new Promise((r) => setTimeout(r, SUPERVISOR_POLL_MS))]);
360
+ }
361
+ }
362
+ finally {
363
+ await cleanup();
364
+ }
365
+ }
366
+ /**
367
+ * Start slot-queued jobs, oldest first, until the machine is at its limit.
368
+ *
369
+ * Deliberately has no daemon behind it: this runs on every new dispatch and
370
+ * again as each runner exits, which between them covers every moment a slot
371
+ * can free. The cost of that choice is that if every runner dies while jobs
372
+ * are queued, the queue resumes on the next dispatch rather than immediately.
373
+ * Bounded waiting was the explicit alternative and was not chosen — a queued
374
+ * job keeps its jobId and its artifacts either way, so nothing is lost.
375
+ *
376
+ * NOT called at server start, which was tried and reverted: it silently ran
377
+ * jobs abandoned by a dead session. `orphanStrandedSlotQueue` runs there
378
+ * instead and reports them. See its comment for why reporting beats resuming.
379
+ */
380
+ /**
381
+ * Mark jobs stranded in the slot queue by a server that is gone.
382
+ *
383
+ * Called once at server start, where the reasoning holds unconditionally: this
384
+ * process has not queued anything yet, so anything still slot-queued was
385
+ * queued by a session that no longer exists and nothing will ever drain it —
386
+ * a new dispatch would, but the caller is asking about THIS job, and until
387
+ * they happen to send unrelated work it reads `queued` forever.
388
+ *
389
+ * Deliberately reports rather than runs. Resuming was tried and is worse: a
390
+ * job queued days ago would execute at the next server start, in its original
391
+ * workingDir, at up to `full_auto`, with nobody watching. The job keeps its id
392
+ * and artifacts, so `retry_job` re-runs it as a decision rather than a side
393
+ * effect of opening an editor.
394
+ *
395
+ * The one status this writes back. Orphan detection elsewhere is
396
+ * compute-on-read and never persists its verdict, because the owner might
397
+ * still be alive; here the owner is definitionally gone.
398
+ */
399
+ export async function orphanStrandedSlotQueue() {
400
+ // Only when nothing is left to work the queue.
401
+ //
402
+ // The first version of this reasoned "a server is starting, so anything
403
+ // already slot-queued belongs to a session that is gone". That is false in
404
+ // the configuration this product ships by default: `connect` registers with
405
+ // Claude Code AND Cursor, and `serve` is a third — several servers routinely
406
+ // share one jobs root. An acceptance pass measured the consequence: with
407
+ // server A alive and holding a legitimately queued job, starting server B
408
+ // marked that job orphaned within about a second, and because orphaning
409
+ // clears `slotQueued` the drainer then skipped it forever. Live work,
410
+ // killed, with an error stating a cause that was not true.
411
+ //
412
+ // A supervisor heartbeat answers the question the comment was guessing at.
413
+ // If any supervisor is alive, the queue is being worked and nothing is
414
+ // stranded — a waiting job is waiting, which is what
415
+ // `ux-walkthrough.md` promises it stays.
416
+ if ((await countLiveSupervisors()) > 0)
417
+ return 0;
418
+ const jobs = await listAsyncJobs().catch(() => []);
419
+ let marked = 0;
420
+ for (const status of jobs) {
421
+ if (status.slotQueued !== true)
422
+ continue;
423
+ const { slotQueued: _cleared, ...rest } = status;
424
+ await updateStatus(status.jobDir, {
425
+ ...rest,
426
+ status: "orphaned",
427
+ updatedAt: timestamp(),
428
+ success: false,
429
+ error: "This job was still waiting for a concurrency slot when the dispatch server " +
430
+ "exited, so it never started. It is NOT resumed automatically — re-running " +
431
+ "an abandoned job unattended, in its original working directory, is not " +
432
+ "something a server restart should decide. Use retry_job to run it.",
433
+ }).catch(() => undefined);
434
+ marked += 1;
435
+ }
436
+ return marked;
437
+ }
438
+ export async function drainSlotQueue(config, configPath) {
439
+ const limit = maxConcurrentRuns(config);
440
+ const runnerPath = resolveRunnerPath();
441
+ if (runnerPath === undefined)
442
+ return;
443
+ // ONE drainer at a time, across processes. The body below is a
444
+ // read-count-release: two drainers (every dispatch AND every runner exit
445
+ // calls this) whose reads interleaved with each other's releases could
446
+ // each release a job at active = limit-1 and exceed the cap — the cap that
447
+ // exists because of a measured OOM. The FIFO comment below also assumes a
448
+ // single drainer decides the order; this is what enforces that assumption.
449
+ let releaseDrainLock;
450
+ try {
451
+ releaseDrainLock = await acquireWorkspaceLock(path.join(jobsRoot(), ".slot-drain"), DRAIN_LOCK_TIMEOUT_MS);
452
+ }
453
+ catch {
454
+ // Another process is mid-drain and sees the same queue; this call's
455
+ // trigger is covered by that drain or by the next one (every dispatch and
456
+ // every runner exit re-runs this), so skipping is safe — waiting is not
457
+ // worth blocking a dispatch for.
458
+ return;
459
+ }
460
+ try {
461
+ await drainSlotQueueLocked(limit, runnerPath, configPath, config);
462
+ }
463
+ finally {
464
+ releaseDrainLock();
465
+ }
466
+ }
467
+ /** How long a drain waits for a concurrent drainer before ceding to it. */
468
+ const DRAIN_LOCK_TIMEOUT_MS = 5_000;
469
+ async function drainSlotQueueLocked(limit, runnerPath, configPath, config) {
470
+ const statuses = await readJobStatuses();
471
+ let active = activeCapacity(statuses, config);
472
+ // Supervisors are sized by how many JOBS there are, not by how much budget
473
+ // they consume. Once `active` became a weighted sum these had to part
474
+ // company: ten endpoint calls are 1.0 of capacity but still ten jobs, and
475
+ // sizing the pool off the weight would hand all ten to one supervisor that
476
+ // runs them a few at a time.
477
+ let activeJobs = countActiveJobs(statuses);
478
+ const waiting = statuses.filter((s) => s.status.slotQueued);
479
+ // Release stays HERE, synchronously and oldest-first, even though a
480
+ // supervisor is what will actually run the job. Two reasons: the caller's
481
+ // returned status must still distinguish "got a slot" from "waiting", which
482
+ // it cannot if clearing the flag is deferred to whichever supervisor wakes
483
+ // first; and FIFO across concurrent dispatches is only guaranteed while one
484
+ // drainer decides the order. Supervisors then pick up released work.
485
+ let released = 0;
486
+ for (const { jobDir, status } of waiting) {
487
+ const weight = resourceWeightFor(status, config);
488
+ // The `active > 0` guard prevents a deadlock the plain count could not
489
+ // produce: a single job heavier than the whole budget (weight 1.0 against
490
+ // a capacity of 0.5) would otherwise wait forever for room that can never
491
+ // exist. When nothing is running, the next job always goes — the same
492
+ // reasoning as the earlier fix for a job whose own queued status counted
493
+ // against its own admission.
494
+ if (limit !== null && active > 0 && active + weight > limit)
495
+ break;
496
+ const { slotQueued: _dropped, ...cleared } = status;
497
+ await updateStatus(jobDir, {
498
+ ...cleared,
499
+ updatedAt: timestamp(),
500
+ instructions: pollInstructions(status.jobId),
501
+ });
502
+ active += weight;
503
+ activeJobs += 1;
504
+ released += 1;
505
+ }
506
+ if (released === 0)
507
+ return;
508
+ // Size the pool against ALL outstanding work, not just the jobs released on
509
+ // this call. Dispatches arrive one at a time, so `released` is usually 1;
510
+ // sizing on that gave a single supervisor for twelve jobs, which then ran
511
+ // them three at a time because each supervisor takes only
512
+ // jobsPerSupervisor(limit). The cap must come from the pool size, never from
513
+ // how the work happened to arrive.
514
+ const outstanding = activeJobs;
515
+ const wanted = limit === null
516
+ ? Math.min(SUPERVISOR_POOL_SIZE, outstanding)
517
+ : Math.min(SUPERVISOR_POOL_SIZE, Math.ceil(outstanding / jobsPerSupervisor(limit)));
518
+ const running = await countLiveSupervisors();
519
+ for (let i = running; i < wanted; i += 1) {
520
+ spawnDetachedSupervisor(runnerPath, configPath);
521
+ }
522
+ }
523
+ /**
524
+ * Delete a spawn log that recorded nothing.
525
+ *
526
+ * These are kept on purpose — see the sweep below — because a supervisor that
527
+ * died is exactly the one that left a stale heartbeat, and its bootstrap
528
+ * output is the only explanation of why. That reasoning covers a log with
529
+ * something IN it. It does not cover an empty one, which explains nothing and
530
+ * is what a supervisor that started and exited cleanly leaves behind.
531
+ *
532
+ * Measured on the maintainer's own machine before this: 129 spawn logs going
533
+ * back three weeks, zero live heartbeats, 780 bytes between them — an average
534
+ * of six bytes each. Two independent audits flagged the directory as growing
535
+ * without bound. The sweep reads this directory on every drain, which is the
536
+ * same permanent per-dispatch cost the heartbeat cleanup below was added to
537
+ * stop paying.
538
+ *
539
+ * Only past the staleness threshold, so a live supervisor that has not yet
540
+ * written anything keeps its log.
541
+ */
542
+ async function dropEmptySpawnLog(dir, entry) {
543
+ if (!entry.startsWith("spawn-") || !entry.endsWith(".log"))
544
+ return;
545
+ try {
546
+ const info = await stat(path.join(dir, entry));
547
+ if (info.size > 0)
548
+ return;
549
+ if (Date.now() - info.mtimeMs <= ORPHAN_THRESHOLD_MS)
550
+ return;
551
+ await rm(path.join(dir, entry), { force: true });
552
+ }
553
+ catch {
554
+ // Vanished mid-sweep, or another drain got there first. Either way it is
555
+ // gone, which is the outcome this wanted.
556
+ }
557
+ }
558
+ /**
559
+ * Supervisors currently alive, counted from their heartbeat files.
560
+ *
561
+ * Approximate on purpose: over-counting briefly means the pool runs one short
562
+ * until the next drain, and under-counting means one extra supervisor that
563
+ * finds no work and exits within SUPERVISOR_IDLE_EXIT_MS. Neither warrants a
564
+ * lock, and both self-correct.
565
+ */
566
+ async function countLiveSupervisors() {
567
+ const dir = path.join(jobsRoot(), ".supervisors");
568
+ let entries;
569
+ try {
570
+ entries = await readdir(dir);
571
+ }
572
+ catch {
573
+ return 0;
574
+ }
575
+ let live = 0;
576
+ for (const entry of entries) {
577
+ // Heartbeats only. This directory also holds `spawn-<id>.log`, the
578
+ // bootstrap output of each supervisor — and those exist precisely to
579
+ // explain a supervisor that DIED, which is the same supervisor that left
580
+ // a stale heartbeat. A sweep that treated every file as a heartbeat would
581
+ // delete the diagnostic for the failure it was cleaning up after.
582
+ // Counting was already ignoring them only by accident: a log body does
583
+ // not Date.parse, so it read as not-live.
584
+ if (!entry.endsWith(".txt")) {
585
+ await dropEmptySpawnLog(dir, entry);
586
+ continue;
587
+ }
588
+ try {
589
+ const beat = await readFile(path.join(dir, entry), "utf8");
590
+ if (Date.now() - Date.parse(beat) <= ORPHAN_THRESHOLD_MS) {
591
+ live += 1;
592
+ continue;
593
+ }
594
+ // Dead: remove it rather than only declining to count it.
595
+ //
596
+ // A supervisor that exits cleanly deletes its own file; one that is
597
+ // KILLED cannot, so its heartbeat stopped being counted but stayed on
598
+ // disk forever — and this loop reads every file in the directory on
599
+ // every drain, so the cost of each hard kill was permanent and paid by
600
+ // every dispatch afterwards. Safe to delete: the file is already past
601
+ // the staleness threshold, and a supervisor that somehow revives simply
602
+ // writes it again on its next beat.
603
+ await rm(path.join(dir, entry), { force: true });
604
+ }
605
+ catch {
606
+ // Vanished mid-read, or another drain removed it first: not live, and
607
+ // nothing here needs to succeed for the count to be usable.
608
+ }
609
+ }
610
+ return live;
611
+ }
612
+ /**
613
+ * Exported for the cleanup test, which must exercise the REAL sweep rather
614
+ * than a copy of its logic — the bug being pinned is that a stale heartbeat
615
+ * was never removed, and a reimplementation in the test would pin nothing.
616
+ */
617
+ export const countLiveSupervisorsForTest = countLiveSupervisors;
618
+ /**
619
+ * Start one detached supervisor; it finds its own work.
620
+ *
621
+ * Output goes to a log beside the heartbeats, for the same reason the per-job
622
+ * runner logged to its job dir: a supervisor that dies during bootstrap (bad
623
+ * config, missing module) is otherwise completely silent, and the only symptom
624
+ * is jobs that never start.
625
+ */
626
+ function spawnDetachedSupervisor(runnerPath, configPath) {
627
+ const dir = path.join(jobsRoot(), ".supervisors");
628
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
629
+ const id = `${Date.now()}-${randomUUID().slice(0, 8)}`;
630
+ // Register the slot HERE, before spawning, and hand the id to the child.
631
+ //
632
+ // Letting the supervisor write its own first heartbeat looks tidier and does
633
+ // not work: booting a Node process takes a few hundred ms, so a burst of
634
+ // dispatches all counted zero live supervisors and each spawned another.
635
+ // Measured at 12 concurrent jobs: 12 supervisors, 748 MB — the pool capping
636
+ // nothing at all. The parent claiming the slot synchronously is what makes
637
+ // the cap real.
638
+ writeFileSync(path.join(dir, `${id}.txt`), timestamp(), { encoding: "utf8", mode: 0o600 });
639
+ const logFd = openSync(path.join(dir, `spawn-${id}.log`), "a");
640
+ try {
641
+ const child = spawn(process.execPath, [runnerPath, "--supervisor", id], {
642
+ detached: true,
643
+ stdio: ["ignore", logFd, logFd],
644
+ windowsHide: true,
645
+ env: {
646
+ ...process.env,
647
+ ...(configPath !== undefined ? { HARNESS_DISPATCH_CONFIG: configPath } : {}),
648
+ },
649
+ });
650
+ child.unref();
651
+ }
652
+ finally {
653
+ closeSync(logFd);
654
+ }
655
+ }
656
+ /**
657
+ * Why a detached runner would fail to bootstrap from this config path, if it
658
+ * would. `undefined` means the file loads (or there is none, which is the
659
+ * auto-detect case and always fine).
660
+ *
661
+ * Deliberately re-reads rather than trusting the server's in-memory config:
662
+ * the two disagreeing is exactly the condition being detected.
663
+ */
664
+ export async function configLoadError(configPath) {
665
+ if (configPath === undefined)
666
+ return undefined;
667
+ try {
668
+ await loadConfig(configPath);
669
+ return undefined;
670
+ }
671
+ catch (err) {
672
+ const detail = err instanceof Error ? err.message : String(err);
673
+ return (`cannot start a background run: ${configPath} no longer loads, so the detached ` +
674
+ `runner this dispatch needs cannot start — ${detail}. This server is still using the ` +
675
+ `last config that loaded cleanly, which is why it accepted the request at all. Fix the ` +
676
+ `file (harness-dispatch doctor --config "${configPath}" reports the problem) and retry.`);
677
+ }
678
+ }
679
+ //# sourceMappingURL=supervisor.js.map