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.
- package/CHANGELOG.md +560 -1
- package/README.md +74 -43
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js.map +1 -1
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +166 -29
- package/dist/bin.js.map +1 -1
- package/dist/config/coercions.d.ts +5 -0
- package/dist/config/coercions.d.ts.map +1 -1
- package/dist/config/coercions.js +8 -0
- package/dist/config/coercions.js.map +1 -1
- package/dist/config/protocol.js.map +1 -1
- package/dist/config/validation.d.ts.map +1 -1
- package/dist/config/validation.js.map +1 -1
- package/dist/config.d.ts +11 -3
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +111 -148
- package/dist/config.js.map +1 -1
- package/dist/configure-yaml.d.ts.map +1 -1
- package/dist/configure-yaml.js +57 -13
- package/dist/configure-yaml.js.map +1 -1
- package/dist/dispatch-log.d.ts.map +1 -1
- package/dist/dispatch-log.js +3 -1
- package/dist/dispatch-log.js.map +1 -1
- package/dist/dispatchers/generic-cli.d.ts.map +1 -1
- package/dist/dispatchers/generic-cli.js +189 -97
- package/dist/dispatchers/generic-cli.js.map +1 -1
- package/dist/dispatchers/openai-compatible.d.ts +0 -5
- package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
- package/dist/dispatchers/openai-compatible.js +129 -82
- package/dist/dispatchers/openai-compatible.js.map +1 -1
- package/dist/dispatchers/shared/harness-login.d.ts.map +1 -1
- package/dist/dispatchers/shared/harness-login.js +7 -2
- package/dist/dispatchers/shared/harness-login.js.map +1 -1
- package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
- package/dist/dispatchers/shared/stream-subprocess.js +7 -60
- package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +31 -19
- package/dist/file-lock.js.map +1 -1
- package/dist/harness-presets.d.ts +51 -0
- package/dist/harness-presets.d.ts.map +1 -0
- package/dist/harness-presets.js +154 -0
- package/dist/harness-presets.js.map +1 -0
- package/dist/http/parse.d.ts +8 -1
- package/dist/http/parse.d.ts.map +1 -1
- package/dist/http/parse.js +10 -3
- package/dist/http/parse.js.map +1 -1
- package/dist/http/server.d.ts.map +1 -1
- package/dist/http/server.js +9 -2
- package/dist/http/server.js.map +1 -1
- package/dist/job-runner.js +6 -0
- package/dist/job-runner.js.map +1 -1
- package/dist/jobs/lifecycle.d.ts +100 -0
- package/dist/jobs/lifecycle.d.ts.map +1 -0
- package/dist/jobs/lifecycle.js +263 -0
- package/dist/jobs/lifecycle.js.map +1 -0
- package/dist/jobs/read.d.ts +17 -0
- package/dist/jobs/read.d.ts.map +1 -0
- package/dist/jobs/read.js +103 -0
- package/dist/jobs/read.js.map +1 -0
- package/dist/jobs/run.d.ts +45 -0
- package/dist/jobs/run.d.ts.map +1 -0
- package/dist/jobs/run.js +351 -0
- package/dist/jobs/run.js.map +1 -0
- package/dist/jobs/start.d.ts +9 -0
- package/dist/jobs/start.d.ts.map +1 -0
- package/dist/jobs/start.js +126 -0
- package/dist/jobs/start.js.map +1 -0
- package/dist/jobs/store.d.ts.map +1 -1
- package/dist/jobs/store.js +13 -3
- package/dist/jobs/store.js.map +1 -1
- package/dist/jobs/supervisor.d.ts +151 -0
- package/dist/jobs/supervisor.d.ts.map +1 -0
- package/dist/jobs/supervisor.js +679 -0
- package/dist/jobs/supervisor.js.map +1 -0
- package/dist/jobs.d.ts +15 -233
- package/dist/jobs.d.ts.map +1 -1
- package/dist/jobs.js +14 -1409
- package/dist/jobs.js.map +1 -1
- package/dist/leaderboard.d.ts.map +1 -1
- package/dist/leaderboard.js +5 -1
- package/dist/leaderboard.js.map +1 -1
- package/dist/mcp/config-hot-reload.d.ts.map +1 -1
- package/dist/mcp/config-hot-reload.js +4 -1
- package/dist/mcp/config-hot-reload.js.map +1 -1
- package/dist/mcp/dispatcher-factory.js +1 -1
- package/dist/mcp/dispatcher-factory.js.map +1 -1
- package/dist/mcp/resources.d.ts.map +1 -1
- package/dist/mcp/resources.js +3 -2
- package/dist/mcp/resources.js.map +1 -1
- package/dist/mcp/tools.d.ts +9 -2
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +45 -6
- package/dist/mcp/tools.js.map +1 -1
- package/dist/observability/spans.d.ts.map +1 -1
- package/dist/observability/spans.js +10 -2
- package/dist/observability/spans.js.map +1 -1
- package/dist/redaction.d.ts +90 -0
- package/dist/redaction.d.ts.map +1 -0
- package/dist/redaction.js +221 -0
- package/dist/redaction.js.map +1 -0
- package/dist/router.d.ts +12 -23
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +83 -224
- package/dist/router.js.map +1 -1
- package/dist/state-dir.d.ts.map +1 -1
- package/dist/state-dir.js +15 -1
- package/dist/state-dir.js.map +1 -1
- package/dist/status.d.ts +14 -3
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +36 -3
- package/dist/status.js.map +1 -1
- package/dist/workspace-lock.d.ts +20 -0
- package/dist/workspace-lock.d.ts.map +1 -1
- package/dist/workspace-lock.js +63 -0
- package/dist/workspace-lock.js.map +1 -1
- package/dist/workspace-resolve.d.ts +0 -9
- package/dist/workspace-resolve.d.ts.map +1 -1
- package/dist/workspace-resolve.js +38 -183
- package/dist/workspace-resolve.js.map +1 -1
- package/dist/workspaces.d.ts.map +1 -1
- package/dist/workspaces.js +63 -139
- package/dist/workspaces.js.map +1 -1
- 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
|